From 73da266750b325e5cf1a750cacb5a1d6f777ba60 Mon Sep 17 00:00:00 2001 From: Lukasz Mierzwa Date: Mon, 3 Aug 2026 17:22:33 +0100 Subject: [PATCH] fix(ui): speed up animations --- .../AlertGrid/AlertGroup/Annotation/index.tsx | 22 ++-- ui/src/Components/Grid/AlertGrid/Grid.tsx | 2 +- .../Labels/FilteringCounterBadge/index.tsx | 38 +++---- .../ManagedSilence/SilenceDetails.tsx | 26 ++--- ui/src/Components/OverviewModal/index.tsx | 24 ++-- .../Components/SilenceModal/SilenceForm.tsx | 16 +-- ui/src/Hooks/useFlashTransition.test.tsx | 107 ++++++++++++------ ui/src/Hooks/useFlashTransition.ts | 63 +++++++---- ui/src/Styles/Components/_Flash.scss | 3 +- ui/src/e2e/snapshots/chromium/NavBar.png | Bin 9997 -> 10026 bytes 10 files changed, 166 insertions(+), 135 deletions(-) diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/Annotation/index.tsx b/ui/src/Components/Grid/AlertGrid/AlertGroup/Annotation/index.tsx index e2a2d54eb..a7f9f432e 100644 --- a/ui/src/Components/Grid/AlertGrid/AlertGroup/Annotation/index.tsx +++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/Annotation/index.tsx @@ -2,8 +2,6 @@ import { FC, useEffect, useRef, useState, memo } from "react"; import Linkify from "react-linkify"; -import { CSSTransition } from "react-transition-group"; - import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faExternalLinkAlt } from "@fortawesome/free-solid-svg-icons/faExternalLinkAlt"; import { faAngleLeft } from "@fortawesome/free-solid-svg-icons/faAngleLeft"; @@ -31,7 +29,7 @@ const RenderNonLinkAnnotation: FC<{ } }); - const { ref, props } = useFlashTransition(value); + const { ref } = useFlashTransition(value); const className = "mb-1 p-1 bg-light d-inline-block rounded components-grid-annotation text-break mw-100"; @@ -60,16 +58,14 @@ const RenderNonLinkAnnotation: FC<{ rel: "noopener noreferrer", }} > - - {allowHTML ? ( - - ) : ( - {value} - )} - + {allowHTML ? ( + + ) : ( + {value} + )} ) : ( diff --git a/ui/src/Components/Grid/AlertGrid/Grid.tsx b/ui/src/Components/Grid/AlertGrid/Grid.tsx index b72a462b1..1094c63f7 100644 --- a/ui/src/Components/Grid/AlertGrid/Grid.tsx +++ b/ui/src/Components/Grid/AlertGrid/Grid.tsx @@ -189,7 +189,7 @@ const Grid: FC<{ }, [debouncedRepack, onAlertGridCollapseEvent]); useEffect(() => { - repack(); + debouncedRepack(); }); return ( diff --git a/ui/src/Components/Labels/FilteringCounterBadge/index.tsx b/ui/src/Components/Labels/FilteringCounterBadge/index.tsx index 02b0f3c85..661da3198 100644 --- a/ui/src/Components/Labels/FilteringCounterBadge/index.tsx +++ b/ui/src/Components/Labels/FilteringCounterBadge/index.tsx @@ -2,8 +2,6 @@ import { FC, useCallback, MouseEvent } from "react"; import { observer } from "mobx-react-lite"; -import { CSSTransition } from "react-transition-group"; - import type { AlertStore } from "Stores/AlertStore"; import { QueryOperators, FormatQuery } from "Common/Query"; import { TooltipWrapper } from "Components/TooltipWrapper"; @@ -32,7 +30,7 @@ const FilteringCounterBadge: FC<{ defaultColor = "bg-light", isAppend = true, }) => { - const { ref, props } = useFlashTransition(counter); + const { ref } = useFlashTransition(counter); const handleClick = useCallback( (event: MouseEvent) => { @@ -70,24 +68,22 @@ const FilteringCounterBadge: FC<{ - - - {counter} - - + + {counter} + ); }; diff --git a/ui/src/Components/ManagedSilence/SilenceDetails.tsx b/ui/src/Components/ManagedSilence/SilenceDetails.tsx index 3ebd76f24..a931cf6dc 100644 --- a/ui/src/Components/ManagedSilence/SilenceDetails.tsx +++ b/ui/src/Components/ManagedSilence/SilenceDetails.tsx @@ -6,8 +6,6 @@ import { parseISO } from "date-fns/parseISO"; import copy from "copy-to-clipboard"; -import { CSSTransition } from "react-transition-group"; - import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faEdit } from "@fortawesome/free-solid-svg-icons/faEdit"; import { faCalendarCheck } from "@fortawesome/free-solid-svg-icons/faCalendarCheck"; @@ -30,22 +28,20 @@ const SilenceIDCopyButton: FC<{ id: string; }> = ({ id }) => { const [clickCount, setClickCount] = useState(0); - const { ref, props } = useFlashTransition(clickCount); + const { ref } = useFlashTransition(clickCount); return ( - - { - void copy(id); - setClickCount(clickCount + 1); - }} - > - - - + { + void copy(id); + setClickCount(clickCount + 1); + }} + > + + ); }; diff --git a/ui/src/Components/OverviewModal/index.tsx b/ui/src/Components/OverviewModal/index.tsx index 5eaaa3913..54d74be2f 100644 --- a/ui/src/Components/OverviewModal/index.tsx +++ b/ui/src/Components/OverviewModal/index.tsx @@ -2,8 +2,6 @@ import React, { FC, useState, useCallback } from "react"; import { observer } from "mobx-react-lite"; -import { CSSTransition } from "react-transition-group"; - import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faSpinner } from "@fortawesome/free-solid-svg-icons/faSpinner"; @@ -26,22 +24,20 @@ const OverviewModal: FC<{ const toggle = useCallback(() => setIsVisible(!isVisible), [isVisible]); - const { ref, props } = useFlashTransition(alertStore.info.totalAlerts); + const { ref } = useFlashTransition(alertStore.info.totalAlerts); return ( <> - -
- {alertStore.info.totalAlerts} -
-
+
+ {alertStore.info.totalAlerts} +
@@ -89,7 +87,6 @@ const ShareButton: FC<{ onChange={() => {}} /> { void copy(`${baseURL}?m=${silenceFormStore.data.toBase64}`); @@ -97,14 +94,9 @@ const ShareButton: FC<{ }} > - - } - className="d-inline-block" - > - - - + + + diff --git a/ui/src/Hooks/useFlashTransition.test.tsx b/ui/src/Hooks/useFlashTransition.test.tsx index b1d9e5fdd..f85fcebb3 100644 --- a/ui/src/Hooks/useFlashTransition.test.tsx +++ b/ui/src/Hooks/useFlashTransition.test.tsx @@ -1,4 +1,4 @@ -import { act } from "react"; +import { act, ReactNode } from "react"; import { renderHook } from "@testing-library/react"; @@ -6,7 +6,19 @@ import { useInView } from "react-intersection-observer"; import { mockInViewResponse } from "__fixtures__/InView"; -import { useFlashTransition, defaultProps } from "./useFlashTransition"; +import { ThemeContext, ThemeCtx } from "Components/Theme"; + +import { useFlashTransition } from "./useFlashTransition"; + +const animationsOffCtx: ThemeCtx = { + isDark: false, + reactSelectStyles: {}, + animations: { duration: 0 }, +}; + +const animationsOffWrapper = ({ children }: { children: ReactNode }) => ( + {children} +); describe("useFlashTransition", () => { beforeEach(() => { @@ -20,11 +32,12 @@ describe("useFlashTransition", () => { let value = 0; const { result, rerender } = renderHook(() => useFlashTransition(value)); - expect(result.current.props).toMatchObject(defaultProps); + const node = document.createElement("span"); + act(() => result.current.ref(node)); value = 1; rerender(); - expect(result.current.props).toMatchObject(defaultProps); + expect(node.className).toBe(""); }); it("flashes when value changes and element is in viewport", () => { @@ -34,15 +47,44 @@ describe("useFlashTransition", () => { let value = 2; const { result, rerender } = renderHook(() => useFlashTransition(value)); - expect(result.current.props).toMatchObject(defaultProps); + const node = document.createElement("span"); + act(() => result.current.ref(node)); + expect(node.className).toBe(""); value = 3; rerender(); - expect(result.current.props).toMatchObject({ - ...defaultProps, - in: true, - enter: true, - }); + expect(node.className).toBe("components-animation-flash"); + }); + + it("removes the flash class after the animation duration", () => { + (useInView as jest.MockedFunction).mockReturnValue( + mockInViewResponse(true), + ); + + let value = 2; + const { result, rerender } = renderHook(() => useFlashTransition(value)); + const node = document.createElement("span"); + act(() => result.current.ref(node)); + + value = 3; + rerender(); + expect(node.className).toBe("components-animation-flash"); + + act(() => jest.advanceTimersByTime(800)); + expect(node.className).toBe(""); + }); + + it("does not flash on initial mount", () => { + (useInView as jest.MockedFunction).mockReturnValue( + mockInViewResponse(true), + ); + + const { result, rerender } = renderHook(() => useFlashTransition(1)); + const node = document.createElement("span"); + act(() => result.current.ref(node)); + + rerender(); + expect(node.className).toBe(""); }); it("flashes when value changes and element moves into viewport", () => { @@ -52,42 +94,38 @@ describe("useFlashTransition", () => { let value = 2; const { result, rerender } = renderHook(() => useFlashTransition(value)); + const node = document.createElement("span"); + act(() => result.current.ref(node)); value = 3; rerender(); - expect(result.current.props).toMatchObject(defaultProps); + expect(node.className).toBe(""); - act(() => { - (useInView as jest.MockedFunction).mockReturnValue( - mockInViewResponse(true), - ); - }); + (useInView as jest.MockedFunction).mockReturnValue( + mockInViewResponse(true), + ); rerender(); - expect(result.current.props).toMatchObject({ - ...defaultProps, - in: true, - enter: true, - }); + expect(node.className).toBe("components-animation-flash"); }); - it("stops flashing props.onEntered is called", () => { + it("does not flash when animations are disabled", () => { (useInView as jest.MockedFunction).mockReturnValue( mockInViewResponse(true), ); let value = 2; - const { result, rerender } = renderHook(() => useFlashTransition(value)); + const { result, rerender } = renderHook(() => useFlashTransition(value), { + wrapper: animationsOffWrapper, + }); + const node = document.createElement("span"); + act(() => result.current.ref(node)); value = 3; rerender(); - expect(result.current.props).toMatchObject({ - ...defaultProps, - in: true, - enter: true, - }); + expect(node.className).toBe(""); - act(() => result.current.props.onEntered!({} as HTMLElement, false)); - expect(result.current.props).toMatchObject(defaultProps); + act(() => jest.advanceTimersByTime(800)); + expect(node.className).toBe(""); }); it("unmounts cleanly when not flashing", () => { @@ -101,14 +139,19 @@ describe("useFlashTransition", () => { it("unmounts cleanly when flashing", () => { (useInView as jest.MockedFunction).mockReturnValue( - mockInViewResponse(false), + mockInViewResponse(true), ); let value = 5; - const { rerender, unmount } = renderHook(() => useFlashTransition(value)); + const { result, rerender, unmount } = renderHook(() => + useFlashTransition(value), + ); + const node = document.createElement("span"); + act(() => result.current.ref(node)); value = 6; rerender(); + expect(node.className).toBe("components-animation-flash"); unmount(); }); }); diff --git a/ui/src/Hooks/useFlashTransition.ts b/ui/src/Hooks/useFlashTransition.ts index 6e46d3423..6127f459a 100644 --- a/ui/src/Hooks/useFlashTransition.ts +++ b/ui/src/Hooks/useFlashTransition.ts @@ -1,37 +1,30 @@ import { + use, useState, useEffect, useRef, useCallback, ReactNode, - RefObject, } from "react"; -import type { CSSTransitionProps } from "react-transition-group/CSSTransition"; - import { useInView } from "react-intersection-observer"; -const defaultProps: CSSTransitionProps = { - in: false, - classNames: "components-animation-flash", - timeout: 800, - appear: false, - enter: false, - exit: false, -}; +import { ThemeContext } from "Components/Theme"; + +// must match $duration in Styles/Components/_Flash.scss +const flashDuration = 800; const useFlashTransition = ( flashOn: ReactNode, ): { ref: (node: HTMLElement | null) => void; - props: CSSTransitionProps; - nodeRef: RefObject; } => { + const context = use(ThemeContext); const mountRef = useRef(false); const nodeRef = useRef(null); + const timerRef = useRef | null>(null); const [ref, inView] = useInView(); const [isPending, setIsPending] = useState(false); - const [props, setProps] = useState(defaultProps); useEffect(() => { if (mountRef.current) { @@ -42,15 +35,35 @@ const useFlashTransition = ( }, [flashOn]); useEffect(() => { - setProps({ - ...defaultProps, - in: isPending && inView, - enter: isPending && inView, - onEntered: () => setIsPending(false), - // eslint-disable-next-line @typescript-eslint/no-explicit-any - nodeRef: nodeRef as any, - }); - }, [inView, isPending]); + if (!isPending) { + return; + } + // drop stale flashes rather than play them if animations get re-enabled + if (context.animations.duration === 0) { + setIsPending(false); + return; + } + if (inView && nodeRef.current) { + const node = nodeRef.current; + // adding the class starts the CSS animation, removing it after the + // duration passes allows the next flash to restart it without a reflow + node.classList.add("components-animation-flash"); + timerRef.current = setTimeout( + () => node.classList.remove("components-animation-flash"), + flashDuration, + ); + setIsPending(false); + } + }, [inView, isPending, context.animations.duration]); + + useEffect( + () => () => { + if (timerRef.current) { + clearTimeout(timerRef.current); + } + }, + [], + ); const combinedRef = useCallback( (node: HTMLElement | null) => { @@ -60,7 +73,7 @@ const useFlashTransition = ( [ref], ); - return { ref: combinedRef, props, nodeRef }; + return { ref: combinedRef }; }; -export { useFlashTransition, defaultProps }; +export { useFlashTransition }; diff --git a/ui/src/Styles/Components/_Flash.scss b/ui/src/Styles/Components/_Flash.scss index 60929a6a0..1fc07838e 100644 --- a/ui/src/Styles/Components/_Flash.scss +++ b/ui/src/Styles/Components/_Flash.scss @@ -1,7 +1,6 @@ $duration: 800ms; -.components-animation-flash-appear, -.components-animation-flash-enter { +.components-animation-flash { animation-name: flash; animation-duration: $duration; animation-timing-function: ease-in-out; diff --git a/ui/src/e2e/snapshots/chromium/NavBar.png b/ui/src/e2e/snapshots/chromium/NavBar.png index 4af9d132f4506c32bd9a0417db8bdd5aa89c9711..4bcb42d88a5f1a248623d00cae8c245dc0db4729 100644 GIT binary patch literal 10026 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 XYfQ+?FTLZy8IX;Y{jVj*FWvob6v~NH literal 9997 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