Skip to content

Commit 23bdf86

Browse files
os-zhuangclaude
andcommitted
perf(ux): drive Skeleton pulse on the reanimated UI thread
The skeleton pulse previously called setState on every animation frame, so a loading list of ~15 skeletons re-rendered React ~15×60 times/sec. Move the looped opacity (1 → 0.4 → 1) to a reanimated shared value + UI-thread worklet: identical breathing, zero React re-renders, smoother on device. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 533788e commit 23bdf86

2 files changed

Lines changed: 40 additions & 28 deletions

File tree

‎__tests__/snapshots/__snapshots__/ui-components-extended.test.tsx.snap‎

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -200,9 +200,10 @@ exports[`Skeleton snapshots renders default skeleton 1`] = `
200200
<View
201201
className="rounded-lg bg-muted"
202202
style={
203-
{
204-
"opacity": 1,
205-
}
203+
[
204+
{},
205+
{},
206+
]
206207
}
207208
/>
208209
`;
@@ -211,11 +212,13 @@ exports[`Skeleton snapshots renders with custom dimensions 1`] = `
211212
<View
212213
className="rounded-lg bg-muted"
213214
style={
214-
{
215-
"height": 20,
216-
"opacity": 1,
217-
"width": 200,
218-
}
215+
[
216+
{},
217+
{
218+
"height": 20,
219+
"width": 200,
220+
},
221+
]
219222
}
220223
/>
221224
`;

‎components/ui/Skeleton.tsx‎

Lines changed: 29 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
import React from "react";
2-
import { View, type ViewStyle } from "react-native";
2+
import { type ViewStyle } from "react-native";
3+
import Animated, {
4+
Easing,
5+
useAnimatedStyle,
6+
useSharedValue,
7+
withRepeat,
8+
withSequence,
9+
withTiming,
10+
} from "react-native-reanimated";
311
import { cn } from "~/lib/utils";
412

513
export interface SkeletonProps {
@@ -8,36 +16,37 @@ export interface SkeletonProps {
816
height?: number;
917
}
1018

19+
/**
20+
* A pulsing placeholder block. The pulse runs entirely on the reanimated UI
21+
* thread (a looped opacity 1 → 0.4 → 1), so a list of a dozen skeletons costs
22+
* zero React re-renders while it breathes — previously each frame called
23+
* setState on every skeleton.
24+
*/
1125
export function Skeleton({ className, width, height }: SkeletonProps) {
12-
const [opacity, setOpacity] = React.useState(1);
26+
const opacity = useSharedValue(1);
1327

1428
React.useEffect(() => {
15-
let frame: number;
16-
let start: number | null = null;
29+
opacity.value = withRepeat(
30+
withSequence(
31+
withTiming(0.4, { duration: 750, easing: Easing.inOut(Easing.ease) }),
32+
withTiming(1, { duration: 750, easing: Easing.inOut(Easing.ease) }),
33+
),
34+
-1,
35+
false,
36+
);
37+
}, [opacity]);
1738

18-
const animate = (timestamp: number) => {
19-
if (start === null) start = timestamp;
20-
const elapsed = timestamp - start;
21-
// Pulse between 0.4 and 1.0 over 1.5s
22-
const t = (Math.sin((elapsed / 1500) * Math.PI * 2) + 1) / 2;
23-
setOpacity(0.4 + t * 0.6);
24-
frame = requestAnimationFrame(animate);
25-
};
39+
const animatedStyle = useAnimatedStyle(() => ({ opacity: opacity.value }));
2640

27-
frame = requestAnimationFrame(animate);
28-
return () => cancelAnimationFrame(frame);
29-
}, []);
30-
31-
const style: ViewStyle = {
32-
opacity,
41+
const dims: ViewStyle = {
3342
...(width != null ? { width } : {}),
3443
...(height != null ? { height } : {}),
3544
};
3645

3746
return (
38-
<View
47+
<Animated.View
3948
className={cn("rounded-lg bg-muted", className)}
40-
style={style}
49+
style={[animatedStyle, dims]}
4150
/>
4251
);
4352
}

0 commit comments

Comments
 (0)