diff --git a/.changeset/spinner-css-animation-sync.md b/.changeset/spinner-css-animation-sync.md new file mode 100644 index 00000000000..1a47ed4d810 --- /dev/null +++ b/.changeset/spinner-css-animation-sync.md @@ -0,0 +1,5 @@ +--- +'@primer/react': patch +--- + +perf(Spinner): replace Web Animations API with CSS animation-delay sync diff --git a/packages/react/src/Spinner/Spinner.tsx b/packages/react/src/Spinner/Spinner.tsx index a212b580aa1..7b194238c79 100644 --- a/packages/react/src/Spinner/Spinner.tsx +++ b/packages/react/src/Spinner/Spinner.tsx @@ -1,6 +1,6 @@ import {clsx} from 'clsx' import type React from 'react' -import {useCallback, useEffect, useRef, useState, useSyncExternalStore} from 'react' +import {useEffect, useState} from 'react' import {VisuallyHidden} from '../VisuallyHidden' import type {HTMLDataAttributes} from '../internal/internal-types' import {useId} from '../hooks' @@ -8,6 +8,8 @@ import classes from './Spinner.module.css' import {useMedia} from '../hooks/useMedia' import {useFeatureFlag} from '../FeatureFlags' +const ANIMATION_DURATION_MS = 1000 + const sizeMap = { small: '16px', medium: '32px', @@ -37,18 +39,21 @@ function Spinner({ ...props }: SpinnerProps) { const syncAnimationsEnabled = useFeatureFlag('primer_react_spinner_synchronize_animations') - const animationRef = useSpinnerAnimation() + const noMotionPreference = useMedia('(prefers-reduced-motion: no-preference)', false) const size = sizeMap[sizeKey] const hasHiddenLabel = srText !== null && ariaLabel === undefined const labelId = useId() - const [isVisible, setIsVisible] = useState(!delay) + const [{isVisible, syncDelay}, setVisibleState] = useState(() => ({ + isVisible: !delay, + syncDelay: !delay ? computeSyncDelay() : 0, + })) useEffect(() => { if (delay) { const delayDuration = typeof delay === 'number' ? delay : delay === 'short' ? 300 : 1000 const timeoutId = setTimeout(() => { - setIsVisible(true) + setVisibleState({isVisible: true, syncDelay: computeSyncDelay()}) }, delayDuration) return () => clearTimeout(timeoutId) @@ -59,11 +64,13 @@ function Spinner({ return null } + const shouldSync = syncAnimationsEnabled && noMotionPreference + const mergedStyle = shouldSync ? {...style, animationDelay: `${syncDelay}ms`} : style + return ( /* inline-flex removes the extra line height */ void - -type AnimationTimingValue = { - startTime: CSSNumberish | null -} - -type AnimationTimingStore = { - subscribers: Set - value: AnimationTimingValue - update(startTime: CSSNumberish): void - subscribe(subscriber: Subscriber): () => void - getSnapshot(): AnimationTimingValue - getServerSnapshot(): AnimationTimingValue -} - -const animationTimingStore: AnimationTimingStore = { - subscribers: new Set<() => void>(), - value: { - startTime: null, - }, - update(startTime) { - const value = { - startTime, - } - animationTimingStore.value = value - for (const subscriber of animationTimingStore.subscribers) { - subscriber() - } - }, - subscribe(subscriber) { - animationTimingStore.subscribers.add(subscriber) - return () => { - animationTimingStore.subscribers.delete(subscriber) - } - }, - getSnapshot() { - return animationTimingStore.value - }, - getServerSnapshot() { - return animationTimingStore.value - }, -} - /** - * A utility hook for reading a common `startTime` value so that all animations - * are in sync. This is a global value and is coordinated through `useSyncExternalStore`. + * Computes a negative animation-delay so all spinners land at the same + * rotation angle regardless of when they mount. Because every instance + * references the same clock (performance.now()), the CSS animation engine + * keeps them visually in sync without any Web Animations API calls + * (getAnimations, element.animate, startTime), which are significantly + * slower in Safari/WebKit. */ -function useAnimationTiming() { - return useSyncExternalStore( - animationTimingStore.subscribe, - animationTimingStore.getSnapshot, - animationTimingStore.getServerSnapshot, - ) -} - -/** - * Uses a technique from Spectrum to coordinate animations: - * @see https://github.com/adobe/react-spectrum/blob/ab5e6f3dba4235dafab9f81f8b5c506ce5f11230/packages/%40react-spectrum/s2/src/Skeleton.tsx#L21 - */ -function useSpinnerAnimation() { - const ref = useRef(null) - const noMotionPreference = useMedia('(prefers-reduced-motion: no-preference)', false) - const animationTiming = useAnimationTiming() - return useCallback( - (element: HTMLElement | SVGSVGElement | null) => { - if (!element) { - return - } - - if (ref.current !== null) { - return - } - - if (noMotionPreference) { - const cssAnimation = element.getAnimations().find((animation): animation is CSSAnimation => { - if (animation instanceof CSSAnimation) { - return animation.animationName.startsWith('Spinner') && animation.animationName.endsWith('rotate-keyframes') - } - return false - }) - // If we can find a CSS Animation, pause it and we will use the Web - // Animations API to pick up from where it left off - cssAnimation?.pause() - - ref.current = element.animate( - [ - { - transform: 'rotate(0deg)', - }, - { - transform: 'rotate(360deg)', - }, - ], - { - // var(--base-duration-1000) - duration: 1000, - // var(--base-easing-linear) - easing: 'cubic-bezier(0,0,1,1)', - iterations: Infinity, - }, - ) - - // When the `startTime` value from `animationTimingStore` is `null` we - // are currently hydrating on the client. In this case, the first - // spinner to mount will set the `startTime` for all other spinners. - if (animationTiming.startTime === null) { - const startTime = cssAnimation?.startTime ?? 0 - - animationTimingStore.update(startTime) - - // We use `startTime` to sync different animations. When all animations - // have the same startTime they will be in sync. - // @see https://developer.mozilla.org/en-US/docs/Web/API/Animation/startTime#syncing_different_animations - ref.current.startTime = startTime - } else { - ref.current.startTime = animationTiming.startTime - } - } - }, - [noMotionPreference, animationTiming], - ) +function computeSyncDelay(): number { + const now = typeof performance !== 'undefined' ? performance.now() : 0 + return -(now % ANIMATION_DURATION_MS) } export default Spinner