[READ-ONLY] Mirror of https://github.com/lukebennett88/luke-ui. luke-ui.netlify.app/
0

Configure Feed

Select the types of activity you want to include in your feed.

Move useSynchronizeAnimations and use in LoadingSpinner as well

Luke Bennett (Jul 3, 2026, 12:50 PM +1000) 3724924a 417c948b

+339 -273
+1 -1
packages/@luke-ui/react/src/loading-skeleton/index.tsx
··· 1 1 import type { ComponentProps, ElementType, JSX, ReactNode } from 'react'; 2 2 import { createContext, isValidElement, useContext } from 'react'; 3 3 import * as styles from '../recipes/loading-skeleton.css.js'; 4 + import { useSynchronizeAnimations } from '../use-synchronize-animations/use-synchronize-animations.js'; 4 5 import { cx } from '../utils/index.js'; 5 - import { useSynchronizeAnimations } from './use-synchronize-animations.js'; 6 6 7 7 const LoadingSkeletonContext = createContext<boolean | null>(null); 8 8
-213
packages/@luke-ui/react/src/loading-skeleton/use-synchronize-animations.browser.test.tsx
··· 1 - import type { ReactNode } from 'react'; 2 - import { act } from 'react'; 3 - import type { Root } from 'react-dom/client'; 4 - import { createRoot } from 'react-dom/client'; 5 - import { afterEach, beforeEach, expect, test, vi } from 'vite-plus/test'; 6 - import { useSynchronizeAnimations } from './use-synchronize-animations.js'; 7 - 8 - (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; 9 - 10 - const keyframesSheet = document.createElement('style'); 11 - keyframesSheet.textContent = ` 12 - @keyframes pulse-a { to { opacity: 0.5; } } 13 - @keyframes pulse-b { to { opacity: 0.25; } } 14 - `; 15 - document.head.append(keyframesSheet); 16 - 17 - // The hook schedules its sync in a rAF; capturing callbacks makes flush timing deterministic. 18 - let frameCallbacks: Array<FrameRequestCallback> = []; 19 - let roots: Array<Root> = []; 20 - let containers: Array<HTMLElement> = []; 21 - 22 - const requestFrame = vi.fn<(callback: FrameRequestCallback) => number>((callback) => 23 - frameCallbacks.push(callback), 24 - ); 25 - 26 - function flushFrames() { 27 - while (frameCallbacks.length > 0) { 28 - frameCallbacks.shift()?.(0); 29 - } 30 - } 31 - 32 - function mount(node: ReactNode) { 33 - // Containers must be connected to the document for CSS animations to exist. 34 - const container = document.body.appendChild(document.createElement('div')); 35 - containers.push(container); 36 - const root = createRoot(container); 37 - roots.push(root); 38 - act(() => root.render(node)); 39 - } 40 - 41 - /** Paused so currentTime holds still between arranging state and asserting on it. */ 42 - function AnimatedBox({ name }: { name: string }) { 43 - return ( 44 - <div 45 - style={{ 46 - animationDuration: '1s', 47 - animationIterationCount: 'infinite', 48 - animationName: name, 49 - animationPlayState: 'paused', 50 - animationTimingFunction: 'linear', 51 - }} 52 - /> 53 - ); 54 - } 55 - 56 - function PulsingBox({ name }: { name: string | null }) { 57 - useSynchronizeAnimations(name); 58 - return name ? <AnimatedBox name={name} /> : null; 59 - } 60 - 61 - // Collected via Element.getAnimations so document.getAnimations call counts stay attributable to the hook. 62 - function cssAnimationsNamed(name: string): Array<CSSAnimation> { 63 - return document.body 64 - .getAnimations({ subtree: true }) 65 - .filter( 66 - (animation): animation is CSSAnimation => 67 - animation instanceof CSSAnimation && animation.animationName === name, 68 - ); 69 - } 70 - 71 - beforeEach(() => { 72 - frameCallbacks = []; 73 - roots = []; 74 - containers = []; 75 - requestFrame.mockClear(); 76 - vi.stubGlobal('requestAnimationFrame', requestFrame); 77 - }); 78 - 79 - afterEach(() => { 80 - act(() => roots.forEach((root) => root.unmount())); 81 - // Drain pending frames so the hook's module-level state resets between tests. 82 - flushFrames(); 83 - vi.restoreAllMocks(); 84 - vi.unstubAllGlobals(); 85 - // Remove any own-property override left by the missing-API test, restoring the prototype method. 86 - if (Object.hasOwn(document, 'getAnimations')) { 87 - // @ts-expect-error -- deleting a test-only own property 88 - delete document.getAnimations; 89 - } 90 - containers.forEach((container) => container.remove()); 91 - }); 92 - 93 - test('aligns every animation with the given name to the first one’s clock', () => { 94 - mount( 95 - <> 96 - <PulsingBox name="pulse-a" /> 97 - <PulsingBox name="pulse-a" /> 98 - <PulsingBox name="pulse-a" /> 99 - </>, 100 - ); 101 - const [first, second, third] = cssAnimationsNamed('pulse-a'); 102 - first!.currentTime = 400; 103 - second!.currentTime = 100; 104 - third!.currentTime = 250; 105 - 106 - flushFrames(); 107 - 108 - expect(cssAnimationsNamed('pulse-a').map((animation) => animation.currentTime)).toEqual([ 109 - 400, 400, 400, 110 - ]); 111 - }); 112 - 113 - test('leaves animations with other names untouched', () => { 114 - mount( 115 - <> 116 - <PulsingBox name="pulse-a" /> 117 - <AnimatedBox name="pulse-b" /> 118 - <PulsingBox name="pulse-a" /> 119 - </>, 120 - ); 121 - const [first, second] = cssAnimationsNamed('pulse-a'); 122 - first!.currentTime = 400; 123 - second!.currentTime = 100; 124 - const [other] = cssAnimationsNamed('pulse-b'); 125 - other!.currentTime = 999; 126 - 127 - flushFrames(); 128 - 129 - expect(other?.currentTime).toBe(999); 130 - }); 131 - 132 - test('ignores script-created animations', () => { 133 - mount(<PulsingBox name="pulse-a" />); 134 - const [container] = containers; 135 - if (!container) throw new Error('mount() did not register a container'); 136 - // An element.animate() Animation is not a CSSAnimation, so the hook must never retime it. 137 - const scripted = container.animate([{ opacity: 1 }, { opacity: 0.5 }], { 138 - duration: 1000, 139 - iterations: Number.POSITIVE_INFINITY, 140 - }); 141 - scripted.pause(); 142 - scripted.currentTime = 50; 143 - const [css] = cssAnimationsNamed('pulse-a'); 144 - if (!css) throw new Error('expected a pulse-a CSSAnimation'); 145 - css.currentTime = 400; 146 - 147 - flushFrames(); 148 - 149 - expect(scripted.currentTime).toBe(50); 150 - scripted.cancel(); 151 - }); 152 - 153 - test('batches same-frame mounts into a single getAnimations pass', () => { 154 - const getAnimations = vi.spyOn(document, 'getAnimations'); 155 - 156 - mount( 157 - <> 158 - <PulsingBox name="pulse-a" /> 159 - <PulsingBox name="pulse-a" /> 160 - <PulsingBox name="pulse-a" /> 161 - </>, 162 - ); 163 - const [first, second] = cssAnimationsNamed('pulse-a'); 164 - first!.currentTime = 400; 165 - second!.currentTime = 100; 166 - 167 - flushFrames(); 168 - 169 - expect(getAnimations).toHaveBeenCalledTimes(1); 170 - expect(second?.currentTime).toBe(400); 171 - }); 172 - 173 - test('schedules no work for a falsy animation name', () => { 174 - const getAnimations = vi.spyOn(document, 'getAnimations'); 175 - 176 - mount( 177 - <> 178 - <AnimatedBox name="pulse-a" /> 179 - <PulsingBox name={null} /> 180 - </>, 181 - ); 182 - const [animation] = cssAnimationsNamed('pulse-a'); 183 - animation!.currentTime = 400; 184 - 185 - flushFrames(); 186 - 187 - expect(requestFrame).not.toHaveBeenCalled(); 188 - expect(getAnimations).not.toHaveBeenCalled(); 189 - expect(animation?.currentTime).toBe(400); 190 - }); 191 - 192 - test('mounts without throwing in browsers missing the Web Animations API', () => { 193 - vi.stubGlobal('CSSAnimation', undefined); 194 - // Simulate a browser without document.getAnimations by shadowing the prototype method. 195 - Object.defineProperty(document, 'getAnimations', { configurable: true, value: undefined }); 196 - 197 - expect(() => mount(<PulsingBox name="pulse-a" />)).not.toThrow(); 198 - expect(requestFrame).not.toHaveBeenCalled(); 199 - }); 200 - 201 - test('syncs animations mounted after an earlier sync frame has completed', () => { 202 - mount(<PulsingBox name="pulse-a" />); 203 - const [first] = cssAnimationsNamed('pulse-a'); 204 - first!.currentTime = 400; 205 - flushFrames(); 206 - 207 - mount(<PulsingBox name="pulse-a" />); 208 - flushFrames(); 209 - 210 - expect(cssAnimationsNamed('pulse-a').map((animation) => animation.currentTime)).toEqual([ 211 - 400, 400, 212 - ]); 213 - });
-50
packages/@luke-ui/react/src/loading-skeleton/use-synchronize-animations.ts
··· 1 - import { useEffect, useLayoutEffect } from 'react'; 2 - 3 - // useLayoutEffect causes warnings during SSR; fall back to useEffect on the server. 4 - const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect; 5 - 6 - /** 7 - * Synchronises the CSS animation identified by `animationName` across every element currently playing it, so they 8 - * all pulse in lockstep rather than each starting from their own offset. 9 - * 10 - * Pass the animation name string (e.g. `skeletonAnimationName`), or a falsy value to skip syncing (e.g. when the 11 - * animated element isn't rendered). 12 - */ 13 - export function useSynchronizeAnimations(animationName: string | null | undefined): void { 14 - useIsomorphicLayoutEffect(() => { 15 - if (animationName) scheduleSync(animationName); 16 - }, [animationName]); 17 - } 18 - 19 - // Animation names awaiting a sync, drained by a single rAF so any number of 20 - // elements mounting in the same frame costs one pass over document.getAnimations(). 21 - const pendingNames = new Set<string>(); 22 - let frameId: number | null = null; 23 - 24 - function scheduleSync(animationName: string): void { 25 - // Pre-2020 browsers lack these Web Animations APIs; animations still run, just not in lockstep. 26 - if (typeof CSSAnimation === 'undefined' || typeof document.getAnimations !== 'function') { 27 - return; 28 - } 29 - 30 - pendingNames.add(animationName); 31 - if (frameId !== null) return; 32 - 33 - frameId = requestAnimationFrame(() => { 34 - frameId = null; 35 - // Align every animation to the clock of the first one seen with its name. 36 - const referenceTimes = new Map<string, number>(); 37 - for (const animation of document.getAnimations()) { 38 - if (!(animation instanceof CSSAnimation) || !pendingNames.has(animation.animationName)) { 39 - continue; 40 - } 41 - const referenceTime = referenceTimes.get(animation.animationName); 42 - if (referenceTime !== undefined) { 43 - animation.currentTime = referenceTime; 44 - } else if (typeof animation.currentTime === 'number') { 45 - referenceTimes.set(animation.animationName, animation.currentTime); 46 - } 47 - } 48 - pendingNames.clear(); 49 - }); 50 - }
+4
packages/@luke-ui/react/src/loading-spinner/index.tsx
··· 9 9 SPINNER_STROKE_WIDTH, 10 10 } from '../sizing/icon-sizing.js'; 11 11 import type { DistributiveOmit } from '../types/distributive-omit.js'; 12 + import { useSynchronizeAnimations } from '../use-synchronize-animations/use-synchronize-animations.js'; 12 13 import { cx } from '../utils/index.js'; 13 14 14 15 interface LoadingSpinnerVariantProps extends NonNullable<styles.LoadingSpinnerVariants> {} ··· 69 70 const progress = ((clampedValue - normalizedMin) / clampedRange) * 100; 70 71 const dashOffset = 100 - progress; 71 72 const mode = hasValue ? 'determinate' : 'indeterminate'; 73 + 74 + useSynchronizeAnimations(mode === 'indeterminate' ? styles.spinAnimationName : null); 75 + useSynchronizeAnimations(mode === 'indeterminate' ? styles.rubberBandAnimationName : null); 72 76 73 77 return ( 74 78 <div
+3
packages/@luke-ui/react/src/loading-spinner/loading-spinner.docs.md
··· 5 5 <LoadingSpinner aria-label="Loading" /> 6 6 ``` 7 7 8 + All mounted indeterminate spinners rotate and pulse in sync, even when they mount at different 9 + times. 10 + 8 11 ## Progress mode 9 12 10 13 Omit `value` for indeterminate progress. Pass `value` for determinate.
+57 -4
packages/@luke-ui/react/src/loading-spinner/loading-spinner.stories.tsx
··· 1 1 import type { LoadingSpinnerProps } from '@luke-ui/react/loading-spinner'; 2 2 import { LoadingSpinner } from '@luke-ui/react/loading-spinner'; 3 + import { spinAnimationName } from '@luke-ui/react/recipes'; 3 4 import { tokenKeys, tokens } from '@luke-ui/react/tokens'; 4 5 import type { CSSProperties } from 'react'; 5 - import { expect } from 'storybook/test'; 6 + import { useState } from 'react'; 7 + import { expect, userEvent } from 'storybook/test'; 6 8 import preview from '../../.storybook/preview.js'; 7 9 8 10 const meta = preview.meta({ ··· 15 17 'aria-label': 'pending', 16 18 } as const satisfies Partial<LoadingSpinnerProps>; 17 19 18 - const stackStyle = { 20 + const flexRowStyle = { 21 + display: 'flex', 22 + gap: '1rem', 23 + } as const satisfies CSSProperties; 24 + 25 + const flexStackStyle = { 19 26 alignItems: 'center', 20 27 display: 'flex', 28 + flexDirection: 'column', 21 29 gap: '1rem', 22 30 } as const satisfies CSSProperties; 23 31 ··· 39 47 export const Size = meta.story({ 40 48 args: baseArgs, 41 49 render: (props) => ( 42 - <div style={stackStyle}> 50 + <div style={flexRowStyle}> 43 51 {sizes.map((size) => ( 44 52 <LoadingSpinner key={size} size={size} {...props} /> 45 53 ))} ··· 55 63 export const Color = meta.story({ 56 64 args: baseArgs, 57 65 render: (props) => ( 58 - <div style={stackStyle}> 66 + <div style={flexRowStyle}> 59 67 {colors.map((color) => ( 60 68 <LoadingSpinner color={color} key={color} {...props} /> 61 69 ))} 62 70 </div> 63 71 ), 64 72 }); 73 + 74 + /** 75 + * All mounted indeterminate spinners rotate in sync, even when they mount at different times. 76 + */ 77 + export const Synchronized = meta.story({ 78 + args: baseArgs, 79 + play: async ({ canvas, canvasElement }) => { 80 + const [first] = findSpinAnimations(canvasElement); 81 + if (!first) throw new Error('Expected a spin CSS animation.'); 82 + first.currentTime = 400; 83 + 84 + await userEvent.click(canvas.getByRole('button', { name: 'Mount another spinner' })); 85 + await new Promise(requestAnimationFrame); 86 + 87 + const [, second] = findSpinAnimations(canvasElement); 88 + await expect(second?.currentTime).toBe(400); 89 + }, 90 + render: (props) => <StaggeredSpinners {...props} />, 91 + }); 92 + 93 + function StaggeredSpinners(props: LoadingSpinnerProps) { 94 + const [spinnerCount, setSpinnerCount] = useState(1); 95 + 96 + return ( 97 + <div style={flexStackStyle}> 98 + <div style={flexRowStyle}> 99 + {Array.from({ length: spinnerCount }, (_, index) => ( 100 + <LoadingSpinner key={index} {...props} /> 101 + ))} 102 + </div> 103 + <button onClick={() => setSpinnerCount((count) => count + 1)} type="button"> 104 + Mount another spinner 105 + </button> 106 + </div> 107 + ); 108 + } 109 + 110 + function findSpinAnimations(root: Element): Array<CSSAnimation> { 111 + return root 112 + .getAnimations({ subtree: true }) 113 + .filter( 114 + (animation): animation is CSSAnimation => 115 + animation instanceof CSSAnimation && animation.animationName === spinAnimationName, 116 + ); 117 + }
+5 -1
packages/@luke-ui/react/src/recipes/index.ts
··· 13 13 skeletonBorderRadiusVar, 14 14 } from '../recipes/loading-skeleton.css.js'; 15 15 export type { LoadingSpinnerVariants } from '../recipes/loading-spinner.css.js'; 16 - export { spinner as loadingSpinner } from '../recipes/loading-spinner.css.js'; 16 + export { 17 + rubberBandAnimationName, 18 + spinAnimationName, 19 + spinner as loadingSpinner, 20 + } from '../recipes/loading-spinner.css.js'; 17 21 export type { 18 22 TextAlign, 19 23 TextColor,
+6 -4
packages/@luke-ui/react/src/recipes/loading-spinner.css.ts
··· 31 31 /** Variant type for the `LoadingSpinner` recipe. */ 32 32 export type LoadingSpinnerVariants = RecipeVariants<typeof spinner>; 33 33 34 - const spin = keyframes({ 34 + /** Generated name of the spinner's rotation animation. Pass to `useSynchronizeAnimations` so every mounted spinner rotates in lockstep. */ 35 + export const spinAnimationName = keyframes({ 35 36 to: { transform: 'rotate(360deg)' }, 36 37 }); 37 38 ··· 45 46 indeterminate: { 46 47 animationDuration: vars.motion.duration.slow, 47 48 animationIterationCount: 'infinite', 48 - animationName: spin, 49 + animationName: spinAnimationName, 49 50 animationTimingFunction: vars.motion.easing.linear, 50 51 }, 51 52 }, ··· 61 62 }, 62 63 }); 63 64 64 - const rubberBand = keyframes({ 65 + /** Generated name of the spinner indicator's rubber-band animation. Pass to `useSynchronizeAnimations` so every mounted spinner pulses in lockstep. */ 66 + export const rubberBandAnimationName = keyframes({ 65 67 '0%': { strokeDasharray: '2 100' }, 66 68 '50%': { strokeDasharray: '65 100', strokeDashoffset: -20 }, 67 69 '100%': { strokeDasharray: '2 100', strokeDashoffset: -100 }, ··· 84 86 indeterminate: { 85 87 animationDuration: vars.motion.duration.slower, 86 88 animationIterationCount: 'infinite', 87 - animationName: rubberBand, 89 + animationName: rubberBandAnimationName, 88 90 animationTimingFunction: vars.motion.easing.emphasized, 89 91 }, 90 92 },
+213
packages/@luke-ui/react/src/use-synchronize-animations/use-synchronize-animations.browser.test.tsx
··· 1 + import type { ReactNode } from 'react'; 2 + import { act } from 'react'; 3 + import type { Root } from 'react-dom/client'; 4 + import { createRoot } from 'react-dom/client'; 5 + import { afterEach, beforeEach, expect, test, vi } from 'vite-plus/test'; 6 + import { useSynchronizeAnimations } from './use-synchronize-animations.js'; 7 + 8 + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; 9 + 10 + const keyframesSheet = document.createElement('style'); 11 + keyframesSheet.textContent = ` 12 + @keyframes pulse-a { to { opacity: 0.5; } } 13 + @keyframes pulse-b { to { opacity: 0.25; } } 14 + `; 15 + document.head.append(keyframesSheet); 16 + 17 + // The hook schedules its sync in a rAF; capturing callbacks makes flush timing deterministic. 18 + let frameCallbacks: Array<FrameRequestCallback> = []; 19 + let roots: Array<Root> = []; 20 + let containers: Array<HTMLElement> = []; 21 + 22 + const requestFrame = vi.fn<(callback: FrameRequestCallback) => number>((callback) => 23 + frameCallbacks.push(callback), 24 + ); 25 + 26 + function flushFrames() { 27 + while (frameCallbacks.length > 0) { 28 + frameCallbacks.shift()?.(0); 29 + } 30 + } 31 + 32 + function mount(node: ReactNode) { 33 + // Containers must be connected to the document for CSS animations to exist. 34 + const container = document.body.appendChild(document.createElement('div')); 35 + containers.push(container); 36 + const root = createRoot(container); 37 + roots.push(root); 38 + act(() => root.render(node)); 39 + } 40 + 41 + /** Paused so currentTime holds still between arranging state and asserting on it. */ 42 + function AnimatedBox({ name }: { name: string }) { 43 + return ( 44 + <div 45 + style={{ 46 + animationDuration: '1s', 47 + animationIterationCount: 'infinite', 48 + animationName: name, 49 + animationPlayState: 'paused', 50 + animationTimingFunction: 'linear', 51 + }} 52 + /> 53 + ); 54 + } 55 + 56 + function PulsingBox({ name }: { name: string | null }) { 57 + useSynchronizeAnimations(name); 58 + return name ? <AnimatedBox name={name} /> : null; 59 + } 60 + 61 + // Collected via Element.getAnimations so document.getAnimations call counts stay attributable to the hook. 62 + function cssAnimationsNamed(name: string): Array<CSSAnimation> { 63 + return document.body 64 + .getAnimations({ subtree: true }) 65 + .filter( 66 + (animation): animation is CSSAnimation => 67 + animation instanceof CSSAnimation && animation.animationName === name, 68 + ); 69 + } 70 + 71 + beforeEach(() => { 72 + frameCallbacks = []; 73 + roots = []; 74 + containers = []; 75 + requestFrame.mockClear(); 76 + vi.stubGlobal('requestAnimationFrame', requestFrame); 77 + }); 78 + 79 + afterEach(() => { 80 + act(() => roots.forEach((root) => root.unmount())); 81 + // Drain pending frames so the hook's module-level state resets between tests. 82 + flushFrames(); 83 + vi.restoreAllMocks(); 84 + vi.unstubAllGlobals(); 85 + // Remove any own-property override left by the missing-API test, restoring the prototype method. 86 + if (Object.hasOwn(document, 'getAnimations')) { 87 + // @ts-expect-error -- deleting a test-only own property 88 + delete document.getAnimations; 89 + } 90 + containers.forEach((container) => container.remove()); 91 + }); 92 + 93 + test('aligns every animation with the given name to the first one’s clock', () => { 94 + mount( 95 + <> 96 + <PulsingBox name="pulse-a" /> 97 + <PulsingBox name="pulse-a" /> 98 + <PulsingBox name="pulse-a" /> 99 + </>, 100 + ); 101 + const [first, second, third] = cssAnimationsNamed('pulse-a'); 102 + first!.currentTime = 400; 103 + second!.currentTime = 100; 104 + third!.currentTime = 250; 105 + 106 + flushFrames(); 107 + 108 + expect(cssAnimationsNamed('pulse-a').map((animation) => animation.currentTime)).toEqual([ 109 + 400, 400, 400, 110 + ]); 111 + }); 112 + 113 + test('leaves animations with other names untouched', () => { 114 + mount( 115 + <> 116 + <PulsingBox name="pulse-a" /> 117 + <AnimatedBox name="pulse-b" /> 118 + <PulsingBox name="pulse-a" /> 119 + </>, 120 + ); 121 + const [first, second] = cssAnimationsNamed('pulse-a'); 122 + first!.currentTime = 400; 123 + second!.currentTime = 100; 124 + const [other] = cssAnimationsNamed('pulse-b'); 125 + other!.currentTime = 999; 126 + 127 + flushFrames(); 128 + 129 + expect(other?.currentTime).toBe(999); 130 + }); 131 + 132 + test('ignores script-created animations', () => { 133 + mount(<PulsingBox name="pulse-a" />); 134 + const [container] = containers; 135 + if (!container) throw new Error('mount() did not register a container'); 136 + // An element.animate() Animation is not a CSSAnimation, so the hook must never retime it. 137 + const scripted = container.animate([{ opacity: 1 }, { opacity: 0.5 }], { 138 + duration: 1000, 139 + iterations: Number.POSITIVE_INFINITY, 140 + }); 141 + scripted.pause(); 142 + scripted.currentTime = 50; 143 + const [css] = cssAnimationsNamed('pulse-a'); 144 + if (!css) throw new Error('expected a pulse-a CSSAnimation'); 145 + css.currentTime = 400; 146 + 147 + flushFrames(); 148 + 149 + expect(scripted.currentTime).toBe(50); 150 + scripted.cancel(); 151 + }); 152 + 153 + test('batches same-frame mounts into a single getAnimations pass', () => { 154 + const getAnimations = vi.spyOn(document, 'getAnimations'); 155 + 156 + mount( 157 + <> 158 + <PulsingBox name="pulse-a" /> 159 + <PulsingBox name="pulse-a" /> 160 + <PulsingBox name="pulse-a" /> 161 + </>, 162 + ); 163 + const [first, second] = cssAnimationsNamed('pulse-a'); 164 + first!.currentTime = 400; 165 + second!.currentTime = 100; 166 + 167 + flushFrames(); 168 + 169 + expect(getAnimations).toHaveBeenCalledTimes(1); 170 + expect(second?.currentTime).toBe(400); 171 + }); 172 + 173 + test('schedules no work for a falsy animation name', () => { 174 + const getAnimations = vi.spyOn(document, 'getAnimations'); 175 + 176 + mount( 177 + <> 178 + <AnimatedBox name="pulse-a" /> 179 + <PulsingBox name={null} /> 180 + </>, 181 + ); 182 + const [animation] = cssAnimationsNamed('pulse-a'); 183 + animation!.currentTime = 400; 184 + 185 + flushFrames(); 186 + 187 + expect(requestFrame).not.toHaveBeenCalled(); 188 + expect(getAnimations).not.toHaveBeenCalled(); 189 + expect(animation?.currentTime).toBe(400); 190 + }); 191 + 192 + test('mounts without throwing in browsers missing the Web Animations API', () => { 193 + vi.stubGlobal('CSSAnimation', undefined); 194 + // Simulate a browser without document.getAnimations by shadowing the prototype method. 195 + Object.defineProperty(document, 'getAnimations', { configurable: true, value: undefined }); 196 + 197 + expect(() => mount(<PulsingBox name="pulse-a" />)).not.toThrow(); 198 + expect(requestFrame).not.toHaveBeenCalled(); 199 + }); 200 + 201 + test('syncs animations mounted after an earlier sync frame has completed', () => { 202 + mount(<PulsingBox name="pulse-a" />); 203 + const [first] = cssAnimationsNamed('pulse-a'); 204 + first!.currentTime = 400; 205 + flushFrames(); 206 + 207 + mount(<PulsingBox name="pulse-a" />); 208 + flushFrames(); 209 + 210 + expect(cssAnimationsNamed('pulse-a').map((animation) => animation.currentTime)).toEqual([ 211 + 400, 400, 212 + ]); 213 + });
+50
packages/@luke-ui/react/src/use-synchronize-animations/use-synchronize-animations.ts
··· 1 + import { useEffect, useLayoutEffect } from 'react'; 2 + 3 + // useLayoutEffect causes warnings during SSR; fall back to useEffect on the server. 4 + const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect; 5 + 6 + /** 7 + * Synchronises the CSS animation identified by `animationName` across every element currently playing it, so they 8 + * all pulse in lockstep rather than each starting from their own offset. 9 + * 10 + * Pass the animation name string (e.g. `skeletonAnimationName`), or a falsy value to skip syncing (e.g. when the 11 + * animated element isn't rendered). 12 + */ 13 + export function useSynchronizeAnimations(animationName: string | null | undefined): void { 14 + useIsomorphicLayoutEffect(() => { 15 + if (animationName) scheduleSync(animationName); 16 + }, [animationName]); 17 + } 18 + 19 + // Animation names awaiting a sync, drained by a single rAF so any number of 20 + // elements mounting in the same frame costs one pass over document.getAnimations(). 21 + const pendingNames = new Set<string>(); 22 + let frameId: number | null = null; 23 + 24 + function scheduleSync(animationName: string): void { 25 + // Pre-2020 browsers lack these Web Animations APIs; animations still run, just not in lockstep. 26 + if (typeof CSSAnimation === 'undefined' || typeof document.getAnimations !== 'function') { 27 + return; 28 + } 29 + 30 + pendingNames.add(animationName); 31 + if (frameId !== null) return; 32 + 33 + frameId = requestAnimationFrame(() => { 34 + frameId = null; 35 + // Align every animation to the clock of the first one seen with its name. 36 + const referenceTimes = new Map<string, number>(); 37 + for (const animation of document.getAnimations()) { 38 + if (!(animation instanceof CSSAnimation) || !pendingNames.has(animation.animationName)) { 39 + continue; 40 + } 41 + const referenceTime = referenceTimes.get(animation.animationName); 42 + if (referenceTime !== undefined) { 43 + animation.currentTime = referenceTime; 44 + } else if (typeof animation.currentTime === 'number') { 45 + referenceTimes.set(animation.animationName, animation.currentTime); 46 + } 47 + } 48 + pendingNames.clear(); 49 + }); 50 + }