From 1543cecf70f746a27edaaa42f90bc137662137e4 Mon Sep 17 00:00:00 2001 From: AnthonyH16 <132026902+AnthonyH16@users.noreply.github.com> Date: Tue, 24 Mar 2026 21:17:46 -0400 Subject: [PATCH] fix: use measurement clone to avoid disrupting popup animations during alignment On some platforms (notably Linux with X11/Wayland compositors), the alignment calculation runs mid-CSS-animation. The previous approach modified the popup element's styles (left, top, transform, overflow) to measure its position, which interfered with active CSS transitions (transition: all) and transforms (transform: scale()) applied during entrance animations. This caused getBoundingClientRect() to return incorrect values, producing wildly wrong popup positions (e.g. top: -20000px). The fix replaces the direct popup style manipulation with a shallow clone (cloneNode(false)) used as a measurement proxy. The clone inherits the popup's classes and attributes but has transform/transition/animation explicitly neutralized. Dimensions are copied via offsetWidth/offsetHeight (not getComputedStyle, which can return empty during the initial render cycle). This allows accurate position measurement without touching the original popup element, fully preserving CSS animations on all platforms. Changes: - Replace placeholder + popup style reset with cloneNode(false) measurement - Copy offsetWidth/offsetHeight to clone for accurate dimensions - Set transform/transition/animation to none on clone only - Measure positions via the clone's getBoundingClientRect() - Remove originLeft/Top/Right/Bottom/Overflow save/restore logic --- src/hooks/useAlign.ts | 75 +++++++++++++++++++++---------------------- tests/align.test.tsx | 70 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+), 38 deletions(-) diff --git a/src/hooks/useAlign.ts b/src/hooks/useAlign.ts index db297856..f07042a2 100644 --- a/src/hooks/useAlign.ts +++ b/src/hooks/useAlign.ts @@ -177,35 +177,38 @@ export default function useAlign( const doc = popupElement.ownerDocument; const win = getWin(popupElement); - const { position: popupPosition } = win.getComputedStyle(popupElement); - - const originLeft = popupElement.style.left; - const originTop = popupElement.style.top; - const originRight = popupElement.style.right; - const originBottom = popupElement.style.bottom; - const originOverflow = popupElement.style.overflow; - // Placement const placementInfo: AlignType = { ...builtinPlacements[placement], ...popupAlign, }; - // placeholder element - const placeholderElement = doc.createElement('div'); - popupElement.parentElement?.appendChild(placeholderElement); - placeholderElement.style.left = `${popupElement.offsetLeft}px`; - placeholderElement.style.top = `${popupElement.offsetTop}px`; - placeholderElement.style.position = popupPosition; - placeholderElement.style.height = `${popupElement.offsetHeight}px`; - placeholderElement.style.width = `${popupElement.offsetWidth}px`; - - // Reset first - popupElement.style.left = '0'; - popupElement.style.top = '0'; - popupElement.style.right = 'auto'; - popupElement.style.bottom = 'auto'; - popupElement.style.overflow = 'hidden'; + // Use a temporary measurement element instead of modifying the popup + // directly. This avoids disrupting CSS animations (transform, transition) + // that may be active on the popup during entrance motion. + // On some platforms (notably Linux), the alignment calculation runs + // mid-animation. Modifying popup styles (left/top/transform) interferes + // with active CSS transitions (transition: all) and transforms + // (transform: scale()), causing getBoundingClientRect() to return + // incorrect values and producing wildly wrong popup positions. + const measureEl = popupElement.cloneNode(false) as HTMLElement; + // Copy layout dimensions to the clone since cloneNode(false) produces + // an empty element whose size would otherwise collapse to 0x0. + // Use offsetWidth/offsetHeight instead of getComputedStyle because + // computed styles may return empty during the initial render cycle. + measureEl.style.width = `${popupElement.offsetWidth}px`; + measureEl.style.height = `${popupElement.offsetHeight}px`; + measureEl.style.left = '0'; + measureEl.style.top = '0'; + measureEl.style.right = 'auto'; + measureEl.style.bottom = 'auto'; + measureEl.style.overflow = 'hidden'; + measureEl.style.transform = 'none'; + measureEl.style.transition = 'none'; + measureEl.style.animation = 'none'; + measureEl.style.visibility = 'hidden'; + measureEl.style.pointerEvents = 'none'; + popupElement.parentElement?.appendChild(measureEl); // Calculate align style, we should consider `transform` case let targetRect: Rect; @@ -227,7 +230,9 @@ export default function useAlign( height: rect.height, }; } - const popupRect = popupElement.getBoundingClientRect(); + // Measure from the temporary element (not affected by CSS transforms + // or transitions on the popup). + const popupRect = measureEl.getBoundingClientRect(); const { height, width } = win.getComputedStyle(popupElement); popupRect.x = popupRect.x ?? popupRect.left; popupRect.y = popupRect.y ?? popupRect.top; @@ -281,22 +286,16 @@ export default function useAlign( ? visibleRegionArea : visibleArea; - // Record right & bottom align data - popupElement.style.left = 'auto'; - popupElement.style.top = 'auto'; - popupElement.style.right = '0'; - popupElement.style.bottom = '0'; - - const popupMirrorRect = popupElement.getBoundingClientRect(); + // Record right & bottom align data using measurement element + measureEl.style.left = 'auto'; + measureEl.style.top = 'auto'; + measureEl.style.right = '0'; + measureEl.style.bottom = '0'; - // Reset back - popupElement.style.left = originLeft; - popupElement.style.top = originTop; - popupElement.style.right = originRight; - popupElement.style.bottom = originBottom; - popupElement.style.overflow = originOverflow; + const popupMirrorRect = measureEl.getBoundingClientRect(); - popupElement.parentElement?.removeChild(placeholderElement); + // Clean up measurement element (popup styles were never modified) + popupElement.parentElement?.removeChild(measureEl); // Calculate scale const scaleX = toNum( diff --git a/tests/align.test.tsx b/tests/align.test.tsx index d4e7386b..5da5f806 100644 --- a/tests/align.test.tsx +++ b/tests/align.test.tsx @@ -333,4 +333,74 @@ describe('Trigger.Align', () => { }), ); }); + + // https://github.com/react-component/trigger/issues/XXX + it('should not modify popup styles during alignment measurement', async () => { + // On some platforms (notably Linux), the alignment calculation runs + // mid-CSS-animation. The fix uses a temporary measurement element + // instead of modifying the popup's styles, so CSS animations + // (transform, transition) are never disrupted. + + render( + } + popupAlign={{ + points: ['tl', 'bl'], + }} + > +
+ , + ); + + await awaitFakeTimer(); + + const popupElement = document.querySelector( + '.rc-trigger-popup', + ) as HTMLElement; + expect(popupElement).toBeTruthy(); + + // Spy on popup style mutations during alignment using property setter + // spies (catches both direct assignment and setProperty) + const styleChanges: string[] = []; + const propsToWatch = ['left', 'top', 'transform', 'transition', 'overflow']; + const restoreSpies: (() => void)[] = []; + + propsToWatch.forEach((prop) => { + const descriptor = Object.getOwnPropertyDescriptor( + CSSStyleDeclaration.prototype, + prop, + ); + if (descriptor?.set) { + const origSet = descriptor.set; + Object.defineProperty(popupElement.style, prop, { + set(value: string) { + styleChanges.push(prop); + origSet.call(this, value); + }, + get: descriptor.get, + configurable: true, + }); + restoreSpies.push(() => { + Object.defineProperty(popupElement.style, prop, descriptor); + }); + } + }); + + // Trigger re-alignment + triggerResize(popupElement); + await awaitFakeTimer(); + + // Restore original property descriptors + restoreSpies.forEach((restore) => restore()); + + // The popup's styles should not have been modified directly during + // measurement (only the final positioning values should be applied + // via the React state update, not during the measurement phase) + expect(styleChanges).not.toContain('left'); + expect(styleChanges).not.toContain('top'); + expect(styleChanges).not.toContain('transform'); + expect(styleChanges).not.toContain('transition'); + expect(styleChanges).not.toContain('overflow'); + }); });