react-component / react-component/trigger

useAlign.js: inline style reset to 0 is silently overridden under prefers-reduced-motion CSS (CSSTransition interpolation)

Offen
#618 1 Kommentar 0 Reaktionen 0 zugewiesene Personen Auf GitHub ansehen

Dieses Issue hat noch niemand übernommen.

Vorherrschende Sprache
TypeScript
Sterne
391
Forks
245
PR-Merge-Kennzahlen
Keine gemergten PRs in 30 T.

Beschreibung

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:

  1. CSS Transitions Level 1 §3.1 places Animations origin above Author Important origin in the cascade
  2. At t=0 of a just-started CSSTransition, the used value equals the starting keyframe (the pre-change value)
  3. getBoundingClientRect() reflects the used value — so it returns the popup's previous position, not the just-written 0

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 getPopupContainer to a different parent → doesn't help, popup still goes through useAlign
  • Forcing CSS position: absolute !important; left: 0 !important; top: 100% !important on .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.

Beitragsleitfaden

Für dieses Repository ist kein Beitragsleitfaden indexiert

Erste Schritte

  1. Lies das ganze Issue und danach den Beitragsleitfaden des Projekts.
  2. Schreib ins Issue, dass du es übernimmst — das erspart doppelte Arbeit.
  3. Forke das Repository und arbeite in einem Branch.
  4. Öffne einen Pull Request, der die Issue-Nummer nennt.

Rechercherichtung

Beginne bei @rc-component/trigger/es/hooks/useAlign.js rund um das Zurücksetzen der Inline-Position und den Aufruf von getBoundingClientRect und untersuche anschließend Popup/index.js auf die anfänglichen Offsets. Reproduziere den Select-Fall unter prefers-reduced-motion und überprüfe, dass die Ausrichtung unterhalb des Triggers bleibt, statt eine große Position außerhalb des sichtbaren Bereichs zu erzeugen.

Vom Indexierungsmodell aus dem Issue-Text verfasst.

Bewertung

Tech-Stack
javascript, react
Bereich
frontend
Issue-Typ
Bug
Schwierigkeit
3/5
Geschätzter Aufwand
1-2 Tage
Aktivitätsstatus
Ruhig
Klarheit
Klar beschrieben
Anfängerfreundlichkeit
65/100

Neue Issues direkt in Ihr Postfach

Eine kurze Übersicht über anfängerfreundliche GitHub-Issues.