fix: popup invisible due to CSSTransition interfering with measurement#639
fix: popup invisible due to CSSTransition interfering with measurement#639baofuen wants to merge 1 commit into
Conversation
react-component#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 react-component#618
|
Someone is attempting to deploy a commit to the afc163's projects Team on Vercel. A member of the Team first needs to authorize it. |
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (2)
Walkthrough本次变更调整弹层对齐测量期间的过渡状态,并移除可见性变化回调中的重复对齐调用;原有对齐计算、偏移、翻转及可见性回调顺序保持不变。 Changes弹层对齐流程
Estimated code review effort: 2 (Simple) | ~10 minutes Possibly related PRs
Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Code Review
This pull request temporarily disables CSS transitions on the popup element during alignment measurements to prevent layout calculation bugs, and removes a redundant onAlign call that caused oscillating offsets. The review feedback recommends using setProperty with 'important' to guarantee that transitions are disabled even when stylesheets use !important rules, and properly restoring or removing the property afterward.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| const originTransition = popupElement.style.transition; | ||
| popupElement.style.transition = 'none'; |
There was a problem hiding this comment.
If a stylesheet applies a transition with !important (for example, @media (prefers-reduced-motion: reduce) { * { transition-duration: 0.01ms !important; } } or a custom transition rule with !important), a normal inline style assignment like style.transition = 'none' will not override it because stylesheet !important declarations take precedence over normal inline styles.
To guarantee that the transition is disabled regardless of stylesheet !important declarations, we should use style.setProperty('transition', 'none', 'important') and capture the original property value and priority to restore them correctly.
| const originTransition = popupElement.style.transition; | |
| popupElement.style.transition = 'none'; | |
| const originTransition = popupElement.style.getPropertyValue('transition') || popupElement.style.transition; | |
| const originPriority = popupElement.style.getPropertyPriority('transition'); | |
| popupElement.style.setProperty('transition', 'none', 'important'); |
| popupElement.style.overflow = originOverflow; | ||
| popupElement.style.overflowX = originOverflowX; | ||
| popupElement.style.overflowY = originOverflowY; | ||
| popupElement.style.transition = originTransition; |
There was a problem hiding this comment.
Restore the original transition style using setProperty with the captured priority, or remove the property if it was not originally set. This ensures that any original inline transition styles (including those with !important) are correctly restored, or completely cleaned up if there were none.
| popupElement.style.transition = originTransition; | |
| if (originTransition) { | |
| popupElement.style.setProperty('transition', originTransition, originPriority); | |
| } else { | |
| popupElement.style.removeProperty('transition'); | |
| } |
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:
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.
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
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.
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
antd@6.3.1 (depends on @rc-component/trigger@3.9.0) + React 19.2:
and interactive.
Fixes #618
Summary by CodeRabbit