useAlign.js: inline style reset to 0 is silently overridden under prefers-reduced-motion CSS (CSSTransition interpolation)
Chưa có ai nhận issue này.
Đánh giá
- Độ khó
- 3/5
- Thời gian dự kiến
- 1-2 ngày
- Mức phù hợp với người mới
- 65/100
- Loại issue
- Lỗi
- Độ rõ ràng
- Đặc tả rõ ràng
- Mức độ hoạt động
- Ít trao đổi
- Công nghệ
- javascript, react
- Lĩnh vực
- frontend
Hướng nghiên cứu
Bắt đầu với @rc-component/trigger/es/hooks/useAlign.js, ở quanh phần đặt lại vị trí inline và lệnh gọi getBoundingClientRect, sau đó kiểm tra Popup/index.js để tìm các offset ban đầu. Tái hiện trường hợp Select với prefers-reduced-motion và xác minh rằng việc căn chỉnh vẫn nằm bên dưới trigger thay vì tạo ra một vị trí lớn nằm ngoài màn hình.
Do mô hình lập chỉ mục viết ra từ nội dung của issue.
Mô tả
useAlign.js: inline style.left='0' reset is ignored under prefers-reduced-motion transition-duration: 0.01ms rule
Target upstream: react-component/trigger / @rc-component/trigger@2.3.1
Summary
useAlign.js resets popupElement.style.left = '0' and popupElement.style.top = '0' before reading the popup's getBoundingClientRect() (to measure the popup at the origin of its offsetParent). This pattern silently breaks when the host page has the widely-recommended accessibility rule:
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
}
}
Under prefers-reduced-motion: reduce, Chrome creates a CSSTransition on left (and top) the moment style.left changes from the initial -1000vw to 0. useAlign then synchronously reads getBoundingClientRect() within the same event-loop turn — but at t=0 of a CSSTransition, the used value equals the starting keyframe (-1000vw), not the newly-assigned 0. The popup's reported x/y stay at (-10800, -17800) instead of the expected (0, 0).
nextOffsetX = triggerRect.left - popupRect.x then becomes 548 - (-10800) = 11348px, which is written back to popupElement.style.left. The popup renders thousands of pixels off-screen. From the user's perspective, clicking the Select "does nothing".
Environment
@rc-component/trigger |
2.3.1 |
antd |
5.29.3 (also reproduces on 5.27.6) |
react |
19.2.0 |
| Browser | Chromium, devicePixelRatio=1 |
| Viewport | 1080 × 1780 |
| OS reduced-motion | enabled (or Chromium headless / DevTools / VS Code Webview — all default to reduced-motion) |
Reproduction
// MRE
import { Select } from 'antd';
// index.css
// @media (prefers-reduced-motion: reduce) {
// *, *::before, *::after { transition-duration: 0.01ms !important; }
// }
<Select options={[{ value: 'a', label: 'A' }, { value: 'b', label: 'B' }]} />
Open in Chromium with "Emulate CSS media feature prefers-reduced-motion: reduce" in DevTools Rendering panel, click the Select. Popup renders at style.left: 11348px (or similar large value) instead of below the trigger.
Root-cause walkthrough
@rc-component/trigger/es/Popup/index.js:76-77 initializes popup inline style with:
const offsetStyle = { left: '-1000vw', top: '-1000vh', right: AUTO, bottom: AUTO };
useAlign.js:147-148 resets the popup to origin before reading rect:
popupElement.style.left = '0';
popupElement.style.top = '0';
// ...
var popupRect = popupElement.getBoundingClientRect(); // line 189
The semantic of lines 147-148 is: "pretend the popup sits at (0,0) of its containing block and read where that would land in the viewport". This semantic is violated when any CSS rule applies a non-zero transition-duration to left/top on the popup, because:
- CSS Transitions Level 1 §3.1 places Animations origin above Author Important origin in the cascade
- At
t=0of a just-started CSSTransition, the used value equals the starting keyframe (the pre-change value) getBoundingClientRect()reflects the used value — so it returns the popup's previous position, not the just-written0
This is demonstrable by probing with inline !important:
popupElement.style.setProperty('left', '1px', 'important');
void popupElement.offsetHeight; // force reflow
getComputedStyle(popupElement).left; // still '-10800px', not '1px'
popupElement.style.getPropertyValue('left'); // '1px' — inline write succeeded
popupElement.style.getPropertyPriority('left'); // 'important' — priority applied
popupElement.getAnimations() returns 2 CSSTransition objects (one on left, one on top), both in playState: 'running' at currentTime: 0, duration: 0.00001s.
Observed DIAG data (instrumented useAlign.js)
popupClass: ant-select-dropdown ant-slide-up-appear ant-slide-up-appear-prepare ant-slide-up css-h52jd
cssTransitionProperty=all duration=1e-05s animationName=none
animations[2]:
CSSTransition/left/running/t=0/d=0.01/kf=offset,easing,composite,left,computedOffset|offset,easing,composite,left,computedOffset
CSSTransition/top /running/t=0/d=0.01/kf=offset,easing,composite,top ,computedOffset|offset,easing,composite,top ,computedOffset
beforeReset: styleL=-1000vw styleT=-1000vh rectX=-10800 rectY=-19135 csL=-10800px csT=-17800px
afterReset: styleL=0px styleT=0px rectX=-10800 rectY=-19135 csL=-10800px csT=-17800px
↑ rect stays at pre-reset position, reset is ineffective
!imp probe: setProperty('left','1px','important') → csL=-10800px ← inline !important is still overridden
!imp readback: styleL=1px[important] styleT=2px[important] ← confirms the write took effect
matchedRules enumeration (stylesheets walk, popupElement.matches()):
[1] :where(.css-h52jdb).ant-select-dropdown { top: -9999px } ← only antd rule, NOT !important, NOT left
Why existing workarounds mislead
Users hitting this will typically try:
- Setting
getPopupContainerto a different parent → doesn't help, popup still goes through useAlign - Forcing CSS
position: absolute !important; left: 0 !important; top: 100% !importanton.ant-select-dropdown→ loses the portal model, gets clipped by modal overflow, disables auto-flip
The problem must be fixed inside useAlign.js because the issue is about synchronous DOM read-after-write during a CSSTransition, which any CSS author-layer workaround cannot override.
Proposed fix — useAlign.js
Disable transitions on the popup element for the duration of the measurement:
// ========================= Align =========================
var onAlign = useEvent(function () {
if (popupEle && target && open) {
var popupElement = popupEle;
// ... existing code ...
+ // Save transition so we can restore it after measuring.
+ var originTransition = popupElement.style.transition;
+ // Temporarily disable all transitions so the subsequent style writes
+ // take effect immediately (CSSTransitions at t=0 otherwise report the
+ // previous used value via getBoundingClientRect / getComputedStyle,
+ // which breaks the alignment math under host-page CSS like
+ // `@media (prefers-reduced-motion: reduce) { * { transition-duration: 0.01ms !important } }`.
+ popupElement.style.transition = 'none';
// Reset first
popupElement.style.left = '0';
popupElement.style.top = '0';
popupElement.style.right = 'auto';
popupElement.style.bottom = 'auto';
popupElement.style.overflow = 'hidden';
// ... existing rect read ...
// Reset back
popupElement.style.left = originLeft;
popupElement.style.top = originTop;
popupElement.style.right = originRight;
popupElement.style.bottom = originBottom;
popupElement.style.overflow = originOverflow;
+ popupElement.style.transition = originTransition;
// ... existing code ...
}
});
This is minimally invasive, does not change alignment math, and works regardless of what host-page CSS rules apply.
Alternative — derive popupRect from offsetParent
A more robust (but larger) fix: since the reset's semantic is "popup at (0,0) of offsetParent", compute popupRect directly from popupElement.offsetParent.getBoundingClientRect(), bypassing the need to ever write to style.left/top for measurement. The downstream patch in our project took this approach successfully (verified working), but it's a larger change that maintainers may want to review more carefully.
Downstream patch (for reference)
Our project is running this patch in production on @rc-component/trigger@2.3.1 via patch-package:
--- a/node_modules/@rc-component/trigger/es/hooks/useAlign.js
+++ b/node_modules/@rc-component/trigger/es/hooks/useAlign.js
@@ -189,6 +189,16 @@
var popupRect = popupElement.getBoundingClientRect();
// ... existing code ...
popupRect.y = (_popupRect$y = popupRect.y) !== null && _popupRect$y !== void 0 ? _popupRect$y : popupRect.top;
+ // PATCH: when the inline `left:0; top:0` reset above is effectively
+ // ignored by an active CSSTransition (Animations origin > author !important,
+ // see CSS Transitions Level 1 §3.1), popupRect still reflects the popup's
+ // previous position. Compute popupRect from offsetParent directly — this
+ // is mathematically equivalent to "popup at (0,0) of offsetParent".
+ if (popupElement.offsetParent) {
+ var _parentRect = popupElement.offsetParent.getBoundingClientRect();
+ popupRect.x = _parentRect.left;
+ popupRect.y = _parentRect.top;
+ popupRect.width = popupElement.offsetWidth;
+ popupRect.height = popupElement.offsetHeight;
+ }
Filed from ClusterDelivery frontend, full root-cause documented with runtime DIAG data.
- Ngôn ngữ chính
- TypeScript
- Star
- 391
- Fork
- 245
- Chỉ số merge pull request
- Không có pull request nào được merge trong 30 ngày
Hướng dẫn đóng góp
Chưa lập chỉ mục được hướng dẫn đóng góp cho kho mã nguồn này
Bắt đầu từ đâu
- Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
- Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
- Fork repository và làm thay đổi trên một nhánh.
- Mở pull request có tham chiếu số hiệu của issue.
Issue khác của react-component/trigger
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 65/100
react-component/trigger#593 · 1 reaction ·
-
Độ khó 3/5 1-2 ngày Mức phù hợp với người mới 58/100
react-component/trigger#623 ·
-
Độ khó 3/5 1-2 ngày Mức phù hợp với người mới 42/100
react-component/trigger#555 ·
-
points: ['cc', 'cc'] 无法自适应调整位置 Đang mở
Độ khó 3/5 1-2 ngày Mức phù hợp với người mới 55/100
react-component/trigger#550 · 1 bình luận ·
-
Độ khó 4/5 3-5 ngày Mức phù hợp với người mới 35/100
react-component/trigger#495 ·
Tất cả issue của react-component/trigger
Issue tương tự
-
clawsweeper:fix-shape-clear clawsweeper:queueable-fix clawsweeper:source-repro impact:ux-friction issue-rating: 🦞 diamond lobster no-stale P3
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 78/100
-
community first-timers-only good first issue hacktoberfest help wanted low hanging fruit up-for-grabs
Độ khó 1/5 Dưới một giờ Mức phù hợp với người mới 76/100
-
code-quality refactoring
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 84/100
github/gh-aw-firewall#8816 ·
-
integration:quickjs org:external priority:backlog topic:code-interpreter topic:middleware type:feature
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 74/100
langchain-ai/deepagents#6450 ·
-
Độ khó 1/5 Dưới một giờ Mức phù hợp với người mới 88/100
vercel/react-tweet#225 ·