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(
+