Skip to content

Commit 669642c

Browse files
committed
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 explicitly neutralized. 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 - Measure positions via the clone's getBoundingClientRect() instead of the popup's, avoiding interference from active animations - Remove originLeft/Top/Right/Bottom/Overflow/Transform save/restore logic since the popup element is no longer modified during measurement
1 parent 59b659d commit 669642c

2 files changed

Lines changed: 77 additions & 38 deletions

File tree

src/hooks/useAlign.ts

Lines changed: 30 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -177,35 +177,31 @@ export default function useAlign(
177177
const doc = popupElement.ownerDocument;
178178
const win = getWin(popupElement);
179179

180-
const { position: popupPosition } = win.getComputedStyle(popupElement);
181-
182-
const originLeft = popupElement.style.left;
183-
const originTop = popupElement.style.top;
184-
const originRight = popupElement.style.right;
185-
const originBottom = popupElement.style.bottom;
186-
const originOverflow = popupElement.style.overflow;
187-
188180
// Placement
189181
const placementInfo: AlignType = {
190182
...builtinPlacements[placement],
191183
...popupAlign,
192184
};
193185

194-
// placeholder element
195-
const placeholderElement = doc.createElement('div');
196-
popupElement.parentElement?.appendChild(placeholderElement);
197-
placeholderElement.style.left = `${popupElement.offsetLeft}px`;
198-
placeholderElement.style.top = `${popupElement.offsetTop}px`;
199-
placeholderElement.style.position = popupPosition;
200-
placeholderElement.style.height = `${popupElement.offsetHeight}px`;
201-
placeholderElement.style.width = `${popupElement.offsetWidth}px`;
202-
203-
// Reset first
204-
popupElement.style.left = '0';
205-
popupElement.style.top = '0';
206-
popupElement.style.right = 'auto';
207-
popupElement.style.bottom = 'auto';
208-
popupElement.style.overflow = 'hidden';
186+
// Use a temporary measurement element instead of modifying the popup
187+
// directly. This avoids disrupting CSS animations (transform, transition)
188+
// that may be active on the popup during entrance motion.
189+
// On some platforms (notably Linux), the alignment calculation runs
190+
// mid-animation. Modifying popup styles (left/top/transform) interferes
191+
// with active CSS transitions (transition: all) and transforms
192+
// (transform: scale()), causing getBoundingClientRect() to return
193+
// incorrect values and producing wildly wrong popup positions.
194+
const measureEl = popupElement.cloneNode(false) as HTMLElement;
195+
measureEl.style.left = '0';
196+
measureEl.style.top = '0';
197+
measureEl.style.right = 'auto';
198+
measureEl.style.bottom = 'auto';
199+
measureEl.style.overflow = 'hidden';
200+
measureEl.style.transform = 'none';
201+
measureEl.style.transition = 'none';
202+
measureEl.style.visibility = 'hidden';
203+
measureEl.style.pointerEvents = 'none';
204+
popupElement.parentElement?.appendChild(measureEl);
209205

210206
// Calculate align style, we should consider `transform` case
211207
let targetRect: Rect;
@@ -227,7 +223,9 @@ export default function useAlign(
227223
height: rect.height,
228224
};
229225
}
230-
const popupRect = popupElement.getBoundingClientRect();
226+
// Measure from the temporary element (not affected by CSS transforms
227+
// or transitions on the popup).
228+
const popupRect = measureEl.getBoundingClientRect();
231229
const { height, width } = win.getComputedStyle(popupElement);
232230
popupRect.x = popupRect.x ?? popupRect.left;
233231
popupRect.y = popupRect.y ?? popupRect.top;
@@ -281,22 +279,16 @@ export default function useAlign(
281279
? visibleRegionArea
282280
: visibleArea;
283281

284-
// Record right & bottom align data
285-
popupElement.style.left = 'auto';
286-
popupElement.style.top = 'auto';
287-
popupElement.style.right = '0';
288-
popupElement.style.bottom = '0';
289-
290-
const popupMirrorRect = popupElement.getBoundingClientRect();
282+
// Record right & bottom align data using measurement element
283+
measureEl.style.left = 'auto';
284+
measureEl.style.top = 'auto';
285+
measureEl.style.right = '0';
286+
measureEl.style.bottom = '0';
291287

292-
// Reset back
293-
popupElement.style.left = originLeft;
294-
popupElement.style.top = originTop;
295-
popupElement.style.right = originRight;
296-
popupElement.style.bottom = originBottom;
297-
popupElement.style.overflow = originOverflow;
288+
const popupMirrorRect = measureEl.getBoundingClientRect();
298289

299-
popupElement.parentElement?.removeChild(placeholderElement);
290+
// Clean up measurement element (popup styles were never modified)
291+
popupElement.parentElement?.removeChild(measureEl);
300292

301293
// Calculate scale
302294
const scaleX = toNum(

tests/align.test.tsx

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,4 +333,51 @@ describe('Trigger.Align', () => {
333333
}),
334334
);
335335
});
336+
337+
// https://github.com/react-component/trigger/issues/XXX
338+
it('should not modify popup styles during alignment measurement', async () => {
339+
// On some platforms (notably Linux), the alignment calculation runs
340+
// mid-CSS-animation. The fix uses a temporary measurement element
341+
// instead of modifying the popup's styles, so CSS animations
342+
// (transform, transition) are never disrupted.
343+
344+
render(
345+
<Trigger
346+
popupVisible
347+
popup={<span className="popup-content" />}
348+
popupAlign={{
349+
points: ['tl', 'bl'],
350+
}}
351+
>
352+
<div className="trigger-target" />
353+
</Trigger>,
354+
);
355+
356+
await awaitFakeTimer();
357+
358+
const popupElement = document.querySelector(
359+
'.rc-trigger-popup',
360+
) as HTMLElement;
361+
expect(popupElement).toBeTruthy();
362+
363+
// Spy on popup style mutations during alignment
364+
const styleChanges: string[] = [];
365+
const origSetProperty = popupElement.style.setProperty.bind(popupElement.style);
366+
popupElement.style.setProperty = function(prop: string, val: string, priority?: string) {
367+
styleChanges.push(prop);
368+
return origSetProperty(prop, val, priority || '');
369+
};
370+
371+
// Trigger re-alignment
372+
triggerResize(popupElement);
373+
await awaitFakeTimer();
374+
375+
// The popup's left/top/transform/transition should not have been
376+
// modified directly during measurement (only the final positioning
377+
// values should be applied via the React state update, not during
378+
// the measurement phase)
379+
expect(styleChanges).not.toContain('transform');
380+
expect(styleChanges).not.toContain('transition');
381+
expect(styleChanges).not.toContain('overflow');
382+
});
336383
});

0 commit comments

Comments
 (0)