From c28e7e8c670e9fb35776017d0648a32d509dcd57 Mon Sep 17 00:00:00 2001 From: baofuen Date: Mon, 13 Jul 2026 16:46:32 +0800 Subject: [PATCH] fix: popup invisible due to CSSTransition interfering with measurement (#618) ## Problem Popup (Dropdown/Select/Tooltip/etc.) renders off-screen and is invisible when the host page applies a non-zero transition-duration to left/top on the popup element. This is caused by two widely-deployed CSS patterns: 1. The accessibility rule recommended by WCAG: @media (prefers-reduced-motion: reduce) { * { transition-duration: 0.01ms !important } } -- active in Chromium headless, VS Code Webview, DevTools emulation, and OS-level reduced-motion. 2. React 19 serialises top/left/right/bottom into the inset shorthand, which can start a CSSTransition on the longhand properties even without explicit prefers-reduced-motion. ## Root cause useAlign resets popupElement.style.left = '0' / style.top = '0' to measure the popup at the origin of its offsetParent, then synchronously reads getBoundingClientRect(). Per CSS Transitions Level 1 section 3.1, Animations origin sits above Author Important origin in the cascade, so at t=0 of a just-started CSSTransition getBoundingClientRect() reports the previous used value (-1000vw/-1000vh from useOffsetStyle) instead of the newly-assigned 0. The offset math then explodes (e.g. triggerRect.bottom - (-7400) = 7438) and the popup renders thousands of pixels off-screen -- from the user's perspective, clicking the trigger "does nothing". Additionally, onVisibleChanged calls onAlign() a second time after motion completes. This re-measures the popup using the first run's offset as the baseline (because the popup has already been moved), producing an oscillating offset: first run computes +41px, second run reads the popup at top: 41px and computes 0px, snapping back. ## Fix 1. useAlign.ts: Temporarily set popupElement.style.transition = 'none' for the duration of the measurement, restoring it afterwards. This prevents CSSTransitions from starting on left/top, so the inline style.left = '0' / style.top = '0' writes take effect immediately and getBoundingClientRect() reports the correct origin. 2. index.tsx: Remove the redundant onAlign() call in onVisibleChanged. The initial alignment is already performed via the motionPrepareResolve useLayoutEffect path; the second call causes the oscillation described above. ## Verification - tsc --noEmit passes with no errors. - Reproduced and verified the fix against a production app using antd@6.3.1 (depends on @rc-component/trigger@3.9.0) + React 19.2: - Before fix: popup at top: -7343px (invisible) on every open. - After fix: popup at top: 41px (trigger.bottom + offset), visible and interactive. Fixes #618 --- src/hooks/useAlign.ts | 27 +++++++++++++++++++++++++++ src/index.tsx | 11 ++++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/hooks/useAlign.ts b/src/hooks/useAlign.ts index 679d0560..57967f63 100644 --- a/src/hooks/useAlign.ts +++ b/src/hooks/useAlign.ts @@ -189,6 +189,32 @@ export default function useAlign( const originOverflowX = popupElement.style.overflowX; const originOverflowY = popupElement.style.overflowY; + // Temporarily disable CSS transitions while measuring the popup. + // + // `useOffsetStyle` positions the popup off-screen (`left: -1000vw; + // top: -1000vh`) before it is aligned. To measure the popup at the + // origin of its offsetParent, `onAlign` writes `style.left = '0'` / + // `style.top = '0'` and then synchronously reads `getBoundingClientRect()`. + // + // If any CSS rule applies a non-zero `transition-duration` to + // `left`/`top` on the popup (e.g. the widely-recommended accessibility + // rule `@media (prefers-reduced-motion: reduce) { * { + // transition-duration: 0.01ms !important } }`, or React 19's `inset` + // shorthand serialisation which can start a transition on the + // longhand properties), a `CSSTransition` is created the moment the + // inline value changes. Per CSS Transitions Level 1 §3.1, Animations + // origin sits above Author Important origin in the cascade, so at + // `t=0` of the just-started transition `getBoundingClientRect()` + // reports the **previous** used value (`-1000vw`/`-1000vh`) instead + // of the newly-assigned `0`. The downstream offset math then explodes + // and the popup renders thousands of pixels off-screen — from the + // user's perspective, clicking the trigger "does nothing". + // + // Setting `transition: none` for the duration of the measurement + // prevents the transition from starting, so the inline write takes + // effect immediately. See issue #618. + const originTransition = popupElement.style.transition; + popupElement.style.transition = 'none'; // Placement const placementInfo: AlignType = { @@ -302,6 +328,7 @@ export default function useAlign( popupElement.style.overflow = originOverflow; popupElement.style.overflowX = originOverflowX; popupElement.style.overflowY = originOverflowY; + popupElement.style.transition = originTransition; popupElement.parentElement?.removeChild(placeholderElement); diff --git a/src/index.tsx b/src/index.tsx index 90f8202d..7ea73ef6 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -558,7 +558,16 @@ export function generateTrigger( // ========================== Motion ============================ const onVisibleChanged = (visible: boolean) => { setInMotion(false); - onAlign(); + // Note: the original code called `onAlign()` here, but the initial + // alignment is already performed via the `motionPrepareResolve` + // `useLayoutEffect` path. Calling `onAlign()` a second time here + // re-measures the popup using the *first* run's offset as the + // baseline (because the popup has already been moved), which produces + // an oscillating offset: first run computes e.g. `+41px`, second run + // reads the popup at `top: 41px` and computes `0px`, snapping the + // popup back to the origin. With the `transition: none` fix in + // `useAlign` this is less severe but still wrong, so skip the + // re-align and keep the stable position from the prepare phase. afterOpenChange?.(visible); afterPopupVisibleChange?.(visible); };