Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/spinner-css-animation-sync.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': patch
---

perf(Spinner): replace Web Animations API with CSS animation-delay sync
147 changes: 22 additions & 125 deletions packages/react/src/Spinner/Spinner.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
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'
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',
Expand Down Expand Up @@ -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)
Expand All @@ -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 */
<span className={classes.Box}>
<svg
ref={syncAnimationsEnabled ? animationRef : undefined}
height={size}
width={size}
viewBox="0 0 16 16"
Expand All @@ -72,7 +79,7 @@ function Spinner({
aria-label={ariaLabel ?? undefined}
aria-labelledby={hasHiddenLabel ? labelId : undefined}
className={clsx(className, classes.SpinnerAnimation)}
style={style}
style={mergedStyle}
{...props}
>
<circle
Expand All @@ -99,127 +106,17 @@ function Spinner({

Spinner.displayName = 'Spinner'

type Subscriber = () => void

type AnimationTimingValue = {
startTime: CSSNumberish | null
}

type AnimationTimingStore = {
subscribers: Set<Subscriber>
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<Animation | null>(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
Loading