Skip to content

Commit 1543cec

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/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
1 parent 59b659d commit 1543cec

File tree

2 files changed

+107
-38
lines changed

2 files changed

+107
-38
lines changed

src/hooks/useAlign.ts

Lines changed: 37 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -177,35 +177,38 @@ 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+
// Copy layout dimensions to the clone since cloneNode(false) produces
196+
// an empty element whose size would otherwise collapse to 0x0.
197+
// Use offsetWidth/offsetHeight instead of getComputedStyle because
198+
// computed styles may return empty during the initial render cycle.
199+
measureEl.style.width = `${popupElement.offsetWidth}px`;
200+
measureEl.style.height = `${popupElement.offsetHeight}px`;
201+
measureEl.style.left = '0';
202+
measureEl.style.top = '0';
203+
measureEl.style.right = 'auto';
204+
measureEl.style.bottom = 'auto';
205+
measureEl.style.overflow = 'hidden';
206+
measureEl.style.transform = 'none';
207+
measureEl.style.transition = 'none';
208+
measureEl.style.animation = 'none';
209+
measureEl.style.visibility = 'hidden';
210+
measureEl.style.pointerEvents = 'none';
211+
popupElement.parentElement?.appendChild(measureEl);
209212

210213
// Calculate align style, we should consider `transform` case
211214
let targetRect: Rect;
@@ -227,7 +230,9 @@ export default function useAlign(
227230
height: rect.height,
228231
};
229232
}
230-
const popupRect = popupElement.getBoundingClientRect();
233+
// Measure from the temporary element (not affected by CSS transforms
234+
// or transitions on the popup).
235+
const popupRect = measureEl.getBoundingClientRect();
231236
const { height, width } = win.getComputedStyle(popupElement);
232237
popupRect.x = popupRect.x ?? popupRect.left;
233238
popupRect.y = popupRect.y ?? popupRect.top;
@@ -281,22 +286,16 @@ export default function useAlign(
281286
? visibleRegionArea
282287
: visibleArea;
283288

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();
289+
// Record right & bottom align data using measurement element
290+
measureEl.style.left = 'auto';
291+
measureEl.style.top = 'auto';
292+
measureEl.style.right = '0';
293+
measureEl.style.bottom = '0';
291294

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;
295+
const popupMirrorRect = measureEl.getBoundingClientRect();
298296

299-
popupElement.parentElement?.removeChild(placeholderElement);
297+
// Clean up measurement element (popup styles were never modified)
298+
popupElement.parentElement?.removeChild(measureEl);
300299

301300
// Calculate scale
302301
const scaleX = toNum(

tests/align.test.tsx

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,4 +333,74 @@ 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 using property setter
364+
// spies (catches both direct assignment and setProperty)
365+
const styleChanges: string[] = [];
366+
const propsToWatch = ['left', 'top', 'transform', 'transition', 'overflow'];
367+
const restoreSpies: (() => void)[] = [];
368+
369+
propsToWatch.forEach((prop) => {
370+
const descriptor = Object.getOwnPropertyDescriptor(
371+
CSSStyleDeclaration.prototype,
372+
prop,
373+
);
374+
if (descriptor?.set) {
375+
const origSet = descriptor.set;
376+
Object.defineProperty(popupElement.style, prop, {
377+
set(value: string) {
378+
styleChanges.push(prop);
379+
origSet.call(this, value);
380+
},
381+
get: descriptor.get,
382+
configurable: true,
383+
});
384+
restoreSpies.push(() => {
385+
Object.defineProperty(popupElement.style, prop, descriptor);
386+
});
387+
}
388+
});
389+
390+
// Trigger re-alignment
391+
triggerResize(popupElement);
392+
await awaitFakeTimer();
393+
394+
// Restore original property descriptors
395+
restoreSpies.forEach((restore) => restore());
396+
397+
// The popup's styles should not have been modified directly during
398+
// measurement (only the final positioning values should be applied
399+
// via the React state update, not during the measurement phase)
400+
expect(styleChanges).not.toContain('left');
401+
expect(styleChanges).not.toContain('top');
402+
expect(styleChanges).not.toContain('transform');
403+
expect(styleChanges).not.toContain('transition');
404+
expect(styleChanges).not.toContain('overflow');
405+
});
336406
});

0 commit comments

Comments
 (0)