[animations] auto disable primitives animations based on user system preferences#6956
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughAdds a new hook to detect prefers-reduced-motion and integrates it into animation modules and tooltip positioning; updates docs, tests, and test setup so Changes
Sequence Diagram(s)sequenceDiagram
participant Component as Chart Component
participant Animate as Animate (JS/CSS)
participant Hook as usePrefersReducedMotion
participant Media as window.matchMedia
Component->>Animate: Render with isAnimationActive='auto'
Animate->>Hook: call usePrefersReducedMotion()
Hook->>Hook: check SSR (Global.isSsr)
alt SSR
Hook-->>Animate: return false
else Not SSR
Hook->>Media: window.matchMedia('(prefers-reduced-motion: reduce)')
Media-->>Hook: MediaQueryList (matches)
Hook->>Hook: subscribe to change events
Hook-->>Animate: return matches (true/false)
end
Animate->>Animate: compute isActive = propActive && !SSR && !prefersReducedMotion
Animate-->>Component: provide computed animation state
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Suggested reviewers
🚥 Pre-merge checks | ✅ 2 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ 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 |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #6956 +/- ##
==========================================
+ Coverage 90.10% 90.13% +0.03%
==========================================
Files 522 525 +3
Lines 38848 39092 +244
Branches 5347 5414 +67
==========================================
+ Hits 35004 35236 +232
- Misses 3835 3847 +12
Partials 9 9 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
Bundle ReportChanges will increase total bundle size by 896 bytes (0.03%) ⬆️. This is within the configured threshold ✅ Detailed changes
ℹ️ *Bundle size includes cached data from a previous commit Affected Assets, Files, and Routes:view changes for bundle: recharts/bundle-cjsAssets Changed:
|
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
src/cartesian/Area.tsx (1)
934-934:⚠️ Potential issue | 🔴 CriticalBug: Area resolves
'auto'without respectingprefers-reduced-motion, contradicting the updated JSDoc.Line 934 resolves
isAnimationActivewith only the SSR check (!Global.isSsr), bypassing theusePrefersReducedMotionhook that lives insideJavascriptAnimate. Meanwhile,InternalAreaProps(line 110) typesisAnimationActiveas plainboolean, so'auto'can never reachJavascriptAnimate.Other components (Bar, Line, Funnel) pass
'auto'through toJavascriptAnimate, where the hook correctly handles both SSR and reduced-motion. Area should do the same.Proposed fix: pass 'auto' through to JavascriptAnimate
Update
InternalAreaPropsto accept'auto':- isAnimationActive: boolean; + isAnimationActive: boolean | 'auto';Then remove the early resolution in
AreaImpl:- isAnimationActive={isAnimationActive === 'auto' ? !Global.isSsr : isAnimationActive} + isAnimationActive={isAnimationActive}src/animation/JavascriptAnimate.tsx (1)
41-78:⚠️ Potential issue | 🟠 MajorAdd unit tests for the
usePrefersReducedMotionhook andisActive='auto'resolution logic.The
isActive='auto'value andusePrefersReducedMotionhook represent new functionality that should be tested to ensure animations correctly respect theprefers-reduced-motionmedia query. Tests should cover:
isActive='auto'withprefers-reduced-motion: reduce→ animations disabledisActive='auto'without reduced motion preference → animations enabledisActive=trueandisActive=false→ hook result ignored- Dynamic media query change after mount
🤖 Fix all issues with AI agents
In @.github/workflows/ci.yml:
- Around line 37-38: The workflow references the wrong integration test repo:
change the repository entries used by the list_integration_tests and
integration_tests checkout steps in .github/workflows/ci.yml from repository:
cloud-walker/recharts-integ and ref: auto-disable-animations back to the
official repository and branch (repository: recharts/recharts-integ and the
appropriate main branch/ref) so CI pulls tests from recharts/recharts-integ;
after the official repo has the auto-disable-animations changes, update the ref
to the new branch if needed.
In `@src/animation/JavascriptAnimate.tsx`:
- Around line 76-78: CSSTransitionAnimate currently resolves isActive only by
checking !Global.isSsr and isActiveProp, missing the user's
prefers-reduced-motion setting; update the CSSTransitionAnimate logic that
computes isActive (the branch handling isActiveProp === 'auto') to also call
usePrefersReducedMotion and require !prefersReducedMotion (same as
JavascriptAnimate) when auto is used, so the computed isActive becomes
isActiveProp === 'auto' ? !Global.isSsr && !prefersReducedMotion : isActiveProp;
ensure you reference the existing symbols CSSTransitionAnimate,
usePrefersReducedMotion, Global, and isActiveProp when making the change.
🧹 Nitpick comments (3)
www/test/vitest.setup.ts (1)
1-14: DuplicatedmatchMediamock — consider extracting a shared helper.This is an exact duplicate of the mock in
test/vitest.setup.ts. If the mock shape evolves (e.g., addingaddListener/removeListener), both files need to stay in sync. Consider extracting a shared helper function.Also applies the same
addListener/removeListenergap noted intest/vitest.setup.ts.src/animation/JavascriptAnimate.tsx (1)
41-61: Add explicit return type annotation and SSR guard inuseEffect.Two items:
- Per coding guidelines, function return values should be explicitly typed.
- While
useEffectdoesn't run on the server, adding a defensive SSR guard in the effect body future-proofs against edge cases whereGlobal.isSsris set in a browser context (e.g., testing scenarios or universal apps that flip this flag).Proposed fix
-function usePrefersReducedMotion() { +function usePrefersReducedMotion(): boolean { const [prefersReducedMotion, setPrefersReducedMotion] = useState(() => { if (Global.isSsr) { return false; } return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }); useEffect(() => { + if (Global.isSsr) { + return undefined; + } const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)'); const handleChange = () => { setPrefersReducedMotion(mediaQuery.matches); }; mediaQuery.addEventListener('change', handleChange); return () => { mediaQuery.removeEventListener('change', handleChange); }; }, []); return prefersReducedMotion; }As per coding guidelines: "Type function parameters and return values explicitly."
src/polar/Radar.tsx (1)
111-115: JSDoc update looks good — minor grammar nit.The tense is inconsistent: "will be disabled" (future) vs. "respects" (present). Consider aligning:
✏️ Suggested wording
- * If set "auto", the animation will be disabled in SSR and respects the user's prefers-reduced-motion system preference for accessibility. + * If set to "auto", the animation will be disabled in SSR and when the user's system enables the prefers-reduced-motion preference (for accessibility).
|
I would like an undefined guard to prevent this problem in jsdom env: recharts/recharts-integ#90 |
…on preference for accessibility
1737669 to
806393c
Compare
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In `@src/cartesian/Scatter.tsx`:
- Around line 306-308: Update the JSDoc sentence in Scatter.tsx (and apply the
same change across other components in this PR) to use parallel verb forms:
change the description for the animation prop from "will be disabled in SSR and
respects the user's prefers-reduced-motion system preference for accessibility"
to "will be disabled in SSR and will respect the user's prefers-reduced-motion
system preference for accessibility" so both clauses use "will" and match voice;
locate the comment block around the animation prop description in the Scatter
component (and identical blocks in other components) and make the wording
consistent.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In `@src/animation/JavascriptAnimate.tsx`:
- Around line 41-68: Add a vitest unit test file for usePrefersReducedMotion
that mocks window.matchMedia and uses a hook renderer (e.g., renderHook + act)
to verify: initial state when matchMedia.matches is true and when false; that
calling the mocked mediaQuery.dispatchEvent / triggering its 'change' calls
updates the hook state; that when window.matchMedia is undefined the hook
returns false; and that on unmount the listener removal is invoked (spy/verify
removeEventListener called). Reference the hook name usePrefersReducedMotion and
the media query string '(prefers-reduced-motion: reduce)' in your tests and
restore/cleanup mocks between tests.
🧹 Nitpick comments (1)
src/animation/JavascriptAnimate.tsx (1)
41-68: Add an explicit return type and eliminate theeslint-disablecomment.
- Per coding guidelines, function return values in
src/**should be explicitly typed:function usePrefersReducedMotion(): boolean.- The
eslint-disable consistent-returnon line 61 can be avoided by wrapping the body in a single guard so there's only one return path.♻️ Suggested refactor
-function usePrefersReducedMotion() { +function usePrefersReducedMotion(): boolean { const [prefersReducedMotion, setPrefersReducedMotion] = useState(() => { if (Global.isSsr) { return false; } if (!window.matchMedia) { return false; } return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }); useEffect(() => { - if (!window.matchMedia) { - return; - } - const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)'); - const handleChange = () => { - setPrefersReducedMotion(mediaQuery.matches); - }; - mediaQuery.addEventListener('change', handleChange); - // eslint-disable-next-line consistent-return - return () => { - mediaQuery.removeEventListener('change', handleChange); - }; + if (window.matchMedia) { + const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)'); + const handleChange = () => { + setPrefersReducedMotion(mediaQuery.matches); + }; + mediaQuery.addEventListener('change', handleChange); + return () => { + mediaQuery.removeEventListener('change', handleChange); + }; + } + return undefined; }, []); return prefersReducedMotion; }As per coding guidelines: "Type function parameters and return values explicitly. Do not rely on implicit any or inference."
…avior for accessibility
…te state management
…omponents for improved accessibility
…educed-motion support
…ior in different scenarios
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In `@src/util/usePrefersReducedMotion.ts`:
- Around line 5-32: Exported hook usePrefersReducedMotion lacks an explicit
return type and JSDoc and uses an eslint-disable for consistent-return; add a
JSDoc block describing the hook and its boolean return, give the function an
explicit return type of boolean, and remove the eslint-disable by restructuring
the effect to early-return when Global.isSsr or window.matchMedia is falsy
(mirror the useState initializer). Also reference the same mediaQuery logic and
state setters (prefersReducedMotion, setPrefersReducedMotion) inside the effect
so the behavior is unchanged.
🧹 Nitpick comments (4)
src/component/TooltipBoundingBox.tsx (4)
76-82: CallingsetStateduring render — valid but fragile pattern.This is the React-sanctioned "adjust state during rendering" pattern (analogous to
getDerivedStateFromProps), so it works. However, it's easy to accidentally introduce an infinite loop if a future change makes the guard condition always true.A slightly safer alternative is to move this into the existing
useEffecton[props.coordinate?.x, props.coordinate?.y], which already fires when coordinates change:♻️ Optional: move dismissal reset into the effect
React.useEffect(() => { + setState(prev => (prev.dismissed ? { ...prev, dismissed: false } : prev)); + const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { setState({ dismissed: true, dismissedAtCoordinate: { x: props.coordinate?.x ?? 0, y: props.coordinate?.y ?? 0, }, }); } }; document.addEventListener('keydown', handleKeyDown); return () => { document.removeEventListener('keydown', handleKeyDown); }; }, [props.coordinate?.x, props.coordinate?.y]);Then remove lines 76–82 entirely.
98-119:visibilityon line 110 is dead code — always overridden on line 117.In
outerStyle, the object spread...positionStyleis followed by an explicitvisibilitykey, which overwrites whateverpositionStyle.visibilitywas. This means thevisibilityset on line 110 insidepositionStylenever reaches the final style object.Either remove it from
positionStyle(line 110) to avoid confusion, or remove the duplicate fromouterStyle(line 117) if the intent is forpositionStyleto be the single source.♻️ Suggested fix — remove from positionStyle
...cssProperties, pointerEvents: 'none', - visibility: !state.dismissed && props.active && props.hasPayload ? 'visible' : 'hidden', position: 'absolute', top: 0, left: 0, };
58-74: Event listener is torn down and re-added on every coordinate change.The
useEffectdepends on[props.coordinate?.x, props.coordinate?.y], so thekeydownlistener is removed and re-registered every time the tooltip moves. SincehandleKeyDownonly needs the current coordinates at the time the key is pressed, you could store coordinates in a ref and use an effect with an empty dependency array to register the listener once.This is a minor optimisation — the current code is correct.
51-56: Missing JSDoc on the component and its props.Per project guidelines, source files should include JSDoc comments (used to autogenerate API docs).
TooltipBoundingBoxPropsandTooltipBoundingBoxImpllack any doc comments. Consider adding at least a brief JSDoc on the exportedTooltipBoundingBoxand the public props interface. As per coding guidelines: "JSDoc comments and TypeScript definitions in source files must be kept up-to-date as they are used to autogenerate API documentation via 'npm run omnidoc'."
|
@PavelVanecek I think its ready for a review |
| if (!this.state.dismissed) { | ||
| return; | ||
| } | ||
| React.useEffect(() => { |
There was a problem hiding this comment.
Nit: why so much formatting going on here?
There was a problem hiding this comment.
I converted this from class to function component, I guess is for that
|
I wonder if this preference is also connected to battery saver mode on smartphones. |
Description
While working on fixing the tests for another PR, We suspected that the animations in the charts might interfere, making them flaky.
So I thought it would be cool to be able to disable them for testing sake, but they are on by default.
But prefers-reduced-motion, media query exists, so this PR aims to do a combo: auto disable recharts animations if the media query triggers, so it would impact library consumers also, and being able to disable the animations from playwright by just simulating the user preference.
Related Issue
#6955
Motivation and Context
Besides the testing purpose, its a good UX practise to let the end user decide if they want to see animations or not.
How Has This Been Tested?
Manually, and with unit tests on usePrefersReducedMotion hook
Types of changes
Its a potential "breaking change" for consumer that has not
isAnimationActiveexplicitly set, and for end users (hopefully they would like that) that has the system preference enabled.Checklist:
Summary by CodeRabbit
New Features
Refactor
Documentation
Tests