fix(ui): speed up animations

This commit is contained in:
Lukasz Mierzwa
2026-08-03 17:57:45 +01:00
committed by Łukasz Mierzwa
parent 7d64866f86
commit 73da266750
10 changed files with 166 additions and 135 deletions
@@ -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",
}}
>
<CSSTransition {...props}>
{allowHTML ? (
<span
ref={ref}
dangerouslySetInnerHTML={{ __html: value }}
></span>
) : (
<span ref={ref}>{value}</span>
)}
</CSSTransition>
{allowHTML ? (
<span
ref={ref}
dangerouslySetInnerHTML={{ __html: value }}
></span>
) : (
<span ref={ref}>{value}</span>
)}
</Linkify>
</>
) : (
+1 -1
View File
@@ -189,7 +189,7 @@ const Grid: FC<{
}, [debouncedRepack, onAlertGridCollapseEvent]);
useEffect(() => {
repack();
debouncedRepack();
});
return (
@@ -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<{
<TooltipWrapper
title={`Click to only show ${name}=${value} alerts or Alt+Click to hide them`}
>
<CSSTransition {...props}>
<span
ref={ref}
className={
themed
? cs.className
: [
`${defaultColor}`,
"rounded-pill components-label-with-hover",
...cs.baseClassNames,
].join(" ")
}
style={themed ? {} : cs.style}
onClick={handleClick}
>
{counter}
</span>
</CSSTransition>
<span
ref={ref}
className={
themed
? cs.className
: [
`${defaultColor}`,
"rounded-pill components-label-with-hover",
...cs.baseClassNames,
].join(" ")
}
style={themed ? {} : cs.style}
onClick={handleClick}
>
{counter}
</span>
</TooltipWrapper>
);
};
@@ -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<number>(0);
const { ref, props } = useFlashTransition(clickCount);
const { ref } = useFlashTransition(clickCount);
return (
<TooltipWrapper title="Copy silence ID to the clipboard">
<CSSTransition {...props}>
<span
ref={ref}
className="badge bg-secondary px-1 me-1 components-label cursor-pointer"
onClick={() => {
void copy(id);
setClickCount(clickCount + 1);
}}
>
<FontAwesomeIcon icon={faCopy} />
</span>
</CSSTransition>
<span
ref={ref}
className="badge bg-secondary px-1 me-1 components-label cursor-pointer"
onClick={() => {
void copy(id);
setClickCount(clickCount + 1);
}}
>
<FontAwesomeIcon icon={faCopy} />
</span>
</TooltipWrapper>
);
};
+10 -14
View File
@@ -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 (
<>
<TooltipWrapper title="Show alert overview">
<CSSTransition {...props}>
<div
ref={ref}
className={`text-center d-inline-block cursor-pointer navbar-brand m-0 components-navbar-button ${
isVisible ? "border-info" : ""
}`}
onClick={toggle}
>
{alertStore.info.totalAlerts}
</div>
</CSSTransition>
<div
ref={ref}
className={`text-center d-inline-block cursor-pointer navbar-brand m-0 components-navbar-button ${
isVisible ? "border-info" : ""
}`}
onClick={toggle}
>
{alertStore.info.totalAlerts}
</div>
</TooltipWrapper>
<Modal size="modal-xl" isOpen={isVisible} toggleOpen={toggle}>
<React.Suspense
+4 -12
View File
@@ -5,8 +5,6 @@ import { observer } from "mobx-react-lite";
import copy from "copy-to-clipboard";
import { CSSTransition } from "react-transition-group";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faPlus } from "@fortawesome/free-solid-svg-icons/faPlus";
import { faUser } from "@fortawesome/free-solid-svg-icons/faUser";
@@ -73,7 +71,7 @@ const ShareButton: FC<{
window.location.pathname,
].join("");
const { ref, props, nodeRef } = useFlashTransition(clickCount);
const { ref } = useFlashTransition(clickCount);
return (
<div className="input-group mb-3">
@@ -89,7 +87,6 @@ const ShareButton: FC<{
onChange={() => {}}
/>
<span
ref={ref}
className="input-group-text text-muted cursor-pointer"
onClick={() => {
void copy(`${baseURL}?m=${silenceFormStore.data.toBase64}`);
@@ -97,14 +94,9 @@ const ShareButton: FC<{
}}
>
<TooltipWrapper title="Copy to clipboard">
<CSSTransition {...props}>
<span
ref={nodeRef as React.RefObject<HTMLSpanElement>}
className="d-inline-block"
>
<FontAwesomeIcon icon={faCopy} />
</span>
</CSSTransition>
<span ref={ref} className="d-inline-block">
<FontAwesomeIcon icon={faCopy} />
</span>
</TooltipWrapper>
</span>
</div>
+75 -32
View File
@@ -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 }) => (
<ThemeContext value={animationsOffCtx}>{children}</ThemeContext>
);
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<typeof useInView>).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<typeof useInView>).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<typeof useInView>).mockReturnValue(
mockInViewResponse(true),
);
});
(useInView as jest.MockedFunction<typeof useInView>).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<typeof useInView>).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<typeof useInView>).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();
});
});
+38 -25
View File
@@ -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<HTMLElement | null>;
} => {
const context = use(ThemeContext);
const mountRef = useRef<boolean>(false);
const nodeRef = useRef<HTMLElement | null>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [ref, inView] = useInView();
const [isPending, setIsPending] = useState<boolean>(false);
const [props, setProps] = useState<CSSTransitionProps>(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 };
+1 -2
View File
@@ -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;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.8 KiB

After

Width:  |  Height:  |  Size: 9.8 KiB