Skip to content

Commit 01513de

Browse files
authored
🤖 perf: fix shimmer frame drops + use requestIdleCallback for streaming (#986)
## Problem The `Shimmer` component was using `motion/react` with `backgroundPosition` animation which runs on the **main thread**, causing significant frame drops during "Thinking" state when the UI is already under load from streaming. ## Solution ### 1. GPU-accelerated shimmer via Web Worker Replaced motion/react with a Web Worker + OffscreenCanvas approach: - Animation loop runs entirely off the main thread - Single worker manages all shimmer instances via Comlink - Main thread only handles initial setup and cleanup - Graceful fallback to main thread if worker fails ### 2. Adaptive streaming updates via requestIdleCallback Replaced fixed 32ms debounce timer with `requestIdleCallback`: - Fast machines get more frequent UI updates - Slow machines naturally throttle without dropping data - 100ms timeout ensures updates happen even under sustained load - Adapts to actual CPU availability rather than hardcoded frame budget ## Files Changed - `src/browser/workers/shimmerWorker.ts` - NEW: Web Worker for off-thread animation - `src/browser/components/ai-elements/shimmer.tsx` - Rewritten with canvas + worker - `src/browser/stores/WorkspaceStore.ts` - requestIdleCallback for delta coalescing --- _Generated with `mux`_
1 parent b2e293c commit 01513de

File tree

3 files changed

+423
-60
lines changed

3 files changed

+423
-60
lines changed
Lines changed: 201 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
"use client";
22

33
import { cn } from "@/common/lib/utils";
4-
import { motion } from "motion/react";
5-
import { type CSSProperties, type ElementType, type JSX, memo, useMemo } from "react";
4+
import * as Comlink from "comlink";
5+
import type { ElementType } from "react";
6+
import { memo, useEffect, useRef } from "react";
7+
import type { ShimmerWorkerAPI } from "@/browser/workers/shimmerWorker";
68

79
export interface TextShimmerProps {
810
children: string;
@@ -13,43 +15,214 @@ export interface TextShimmerProps {
1315
colorClass?: string;
1416
}
1517

18+
// ─────────────────────────────────────────────────────────────────────────────
19+
// Worker Management (singleton)
20+
// ─────────────────────────────────────────────────────────────────────────────
21+
22+
let workerAPI: Comlink.Remote<ShimmerWorkerAPI> | null = null;
23+
let workerFailed = false;
24+
25+
function getWorkerAPI(): Comlink.Remote<ShimmerWorkerAPI> | null {
26+
if (workerFailed) return null;
27+
if (workerAPI) return workerAPI;
28+
29+
try {
30+
const worker = new Worker(new URL("../../workers/shimmerWorker.ts", import.meta.url), {
31+
type: "module",
32+
name: "shimmer-animation",
33+
});
34+
35+
worker.onerror = (e) => {
36+
console.error("[Shimmer] Worker failed to load:", e);
37+
workerFailed = true;
38+
workerAPI = null;
39+
};
40+
41+
workerAPI = Comlink.wrap<ShimmerWorkerAPI>(worker);
42+
return workerAPI;
43+
} catch (e) {
44+
console.error("[Shimmer] Failed to create worker:", e);
45+
workerFailed = true;
46+
return null;
47+
}
48+
}
49+
50+
// ─────────────────────────────────────────────────────────────────────────────
51+
// Shimmer Component
52+
// ─────────────────────────────────────────────────────────────────────────────
53+
54+
/**
55+
* GPU-accelerated shimmer text effect using OffscreenCanvas in a Web Worker.
56+
*
57+
* Renders text with a sweeping highlight animation entirely off the main thread.
58+
* All animation logic runs in a dedicated worker, leaving the main thread free
59+
* for streaming and other UI work.
60+
*/
1661
const ShimmerComponent = ({
1762
children,
18-
as: Component = "p",
63+
as: Component = "span",
1964
className,
2065
duration = 2,
2166
spread = 2,
2267
colorClass = "var(--color-muted-foreground)",
2368
}: TextShimmerProps) => {
24-
const MotionComponent = motion.create(Component as keyof JSX.IntrinsicElements);
69+
const canvasRef = useRef<HTMLCanvasElement>(null);
70+
const instanceIdRef = useRef<number | null>(null);
71+
const transferredRef = useRef(false);
2572

26-
const dynamicSpread = useMemo(() => (children?.length ?? 0) * spread, [children, spread]);
73+
useEffect(() => {
74+
const canvas = canvasRef.current;
75+
if (!canvas) return;
2776

28-
return (
29-
<MotionComponent
30-
animate={{ backgroundPosition: "0% center" }}
31-
className={cn(
32-
"relative bg-[length:250%_100%,auto] bg-clip-text text-transparent",
33-
"[--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--color-background),#0000_calc(50%+var(--spread)))] [background-repeat:no-repeat,padding-box]",
34-
className
35-
)}
36-
data-chromatic="ignore"
37-
initial={{ backgroundPosition: "100% center" }}
38-
style={
39-
{
40-
"--spread": `${dynamicSpread}px`,
41-
backgroundImage: `var(--bg), linear-gradient(${colorClass}, ${colorClass})`,
42-
} as CSSProperties
77+
const api = getWorkerAPI();
78+
79+
// Get computed styles for font matching
80+
const computedStyle = getComputedStyle(canvas);
81+
const font = `${computedStyle.fontWeight} ${computedStyle.fontSize} ${computedStyle.fontFamily}`;
82+
83+
// Resolve CSS variable to actual color
84+
const tempEl = document.createElement("span");
85+
tempEl.style.color = colorClass;
86+
document.body.appendChild(tempEl);
87+
const resolvedColor = getComputedStyle(tempEl).color;
88+
document.body.removeChild(tempEl);
89+
90+
// Get background color for highlight
91+
const bgColor =
92+
getComputedStyle(document.documentElement).getPropertyValue("--color-background").trim() ||
93+
"hsl(0 0% 12%)";
94+
95+
// Measure text and size canvas
96+
const ctx2d = canvas.getContext("2d");
97+
if (!ctx2d) return;
98+
ctx2d.font = font;
99+
const metrics = ctx2d.measureText(children);
100+
const textWidth = Math.ceil(metrics.width);
101+
const ascent = metrics.actualBoundingBoxAscent;
102+
const descent = metrics.actualBoundingBoxDescent;
103+
const textHeight = Math.ceil(ascent + descent);
104+
105+
// Handle HiDPI
106+
const dpr = window.devicePixelRatio || 1;
107+
canvas.width = textWidth * dpr;
108+
canvas.height = textHeight * dpr;
109+
canvas.style.width = `${textWidth}px`;
110+
canvas.style.height = `${textHeight}px`;
111+
canvas.style.verticalAlign = `${-descent}px`;
112+
113+
const config = {
114+
text: children,
115+
font,
116+
color: resolvedColor,
117+
bgColor,
118+
duration,
119+
spread,
120+
dpr,
121+
textWidth,
122+
textHeight,
123+
baselineY: ascent,
124+
};
125+
126+
// Worker path: transfer canvas and register
127+
if (api && !transferredRef.current) {
128+
try {
129+
const offscreen = canvas.transferControlToOffscreen();
130+
transferredRef.current = true;
131+
void api.register(Comlink.transfer(offscreen, [offscreen]), config).then((id) => {
132+
instanceIdRef.current = id;
133+
});
134+
} catch {
135+
// Transfer failed, fall back to main thread
136+
runMainThreadAnimation(canvas, config);
43137
}
44-
transition={{
45-
repeat: Number.POSITIVE_INFINITY,
46-
duration,
47-
ease: "linear",
48-
}}
49-
>
50-
{children}
51-
</MotionComponent>
138+
} else if (api && instanceIdRef.current !== null) {
139+
// Already registered, just update config
140+
void api.update(instanceIdRef.current, config);
141+
} else if (!api) {
142+
// No worker, run on main thread
143+
return runMainThreadAnimation(canvas, config);
144+
}
145+
146+
return () => {
147+
if (api && instanceIdRef.current !== null) {
148+
void api.unregister(instanceIdRef.current);
149+
instanceIdRef.current = null;
150+
}
151+
};
152+
}, [children, colorClass, duration, spread]);
153+
154+
return (
155+
<Component className={cn("inline", className)} data-chromatic="ignore">
156+
<canvas ref={canvasRef} className="inline" />
157+
</Component>
52158
);
53159
};
54160

161+
// ─────────────────────────────────────────────────────────────────────────────
162+
// Main Thread Fallback
163+
// ─────────────────────────────────────────────────────────────────────────────
164+
165+
interface ShimmerConfig {
166+
text: string;
167+
font: string;
168+
color: string;
169+
bgColor: string;
170+
duration: number;
171+
spread: number;
172+
dpr: number;
173+
textWidth: number;
174+
textHeight: number;
175+
baselineY: number;
176+
}
177+
178+
function runMainThreadAnimation(canvas: HTMLCanvasElement, config: ShimmerConfig): () => void {
179+
const ctx = canvas.getContext("2d");
180+
if (!ctx) {
181+
return function cleanup() {
182+
// No animation started - nothing to clean up
183+
};
184+
}
185+
186+
const { text, font, color, bgColor, duration, spread, dpr, textWidth, textHeight, baselineY } =
187+
config;
188+
const durationMs = duration * 1000;
189+
const startTime = performance.now();
190+
let animationId: number;
191+
192+
const animate = (now: number) => {
193+
const elapsed = now - startTime;
194+
const progress = 1 - (elapsed % durationMs) / durationMs;
195+
196+
ctx.clearRect(0, 0, canvas.width, canvas.height);
197+
ctx.save();
198+
ctx.scale(dpr, dpr);
199+
200+
ctx.font = font;
201+
ctx.fillStyle = color;
202+
ctx.fillText(text, 0, baselineY);
203+
204+
const dynamicSpread = (text?.length ?? 0) * spread;
205+
const gradientCenter = progress * textWidth * 2.5 - textWidth * 0.75;
206+
const gradient = ctx.createLinearGradient(
207+
gradientCenter - dynamicSpread,
208+
0,
209+
gradientCenter + dynamicSpread,
210+
0
211+
);
212+
gradient.addColorStop(0, "transparent");
213+
gradient.addColorStop(0.5, bgColor);
214+
gradient.addColorStop(1, "transparent");
215+
216+
ctx.globalCompositeOperation = "source-atop";
217+
ctx.fillStyle = gradient;
218+
ctx.fillRect(0, 0, textWidth, textHeight);
219+
220+
ctx.restore();
221+
animationId = requestAnimationFrame(animate);
222+
};
223+
224+
animationId = requestAnimationFrame(animate);
225+
return () => cancelAnimationFrame(animationId);
226+
}
227+
55228
export const Shimmer = memo(ShimmerComponent);

‎src/browser/stores/WorkspaceStore.ts‎

Lines changed: 36 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -125,10 +125,10 @@ export class WorkspaceStore {
125125
private workspaceMetadata = new Map<string, FrontendWorkspaceMetadata>(); // Store metadata for name lookup
126126
private queuedMessages = new Map<string, QueuedMessage | null>(); // Cached queued messages
127127

128-
// Debounce timers for high-frequency delta events to reduce re-renders during streaming
129-
// Data is always updated immediately in the aggregator; only UI notification is debounced
130-
private deltaDebounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
131-
private static readonly DELTA_DEBOUNCE_MS = 16; // ~60fps cap for smooth streaming
128+
// Idle callback handles for high-frequency delta events to reduce re-renders during streaming.
129+
// Data is always updated immediately in the aggregator; only UI notification is scheduled.
130+
// Using requestIdleCallback adapts to actual CPU availability rather than a fixed timer.
131+
private deltaIdleHandles = new Map<string, number>();
132132

133133
/**
134134
* Map of event types to their handlers. This is the single source of truth for:
@@ -159,7 +159,7 @@ export class WorkspaceStore {
159159
},
160160
"stream-delta": (workspaceId, aggregator, data) => {
161161
aggregator.handleStreamDelta(data as never);
162-
this.debouncedStateBump(workspaceId);
162+
this.scheduleIdleStateBump(workspaceId);
163163
},
164164
"stream-end": (workspaceId, aggregator, data) => {
165165
const streamEndData = data as StreamEndEvent;
@@ -173,7 +173,7 @@ export class WorkspaceStore {
173173
updatePersistedState(getRetryStateKey(workspaceId), createFreshRetryState());
174174

175175
// Flush any pending debounced bump before final bump to avoid double-bump
176-
this.flushPendingDebouncedBump(workspaceId);
176+
this.cancelPendingIdleBump(workspaceId);
177177
this.states.bump(workspaceId);
178178
this.checkAndBumpRecencyIfChanged();
179179
this.finalizeUsageStats(workspaceId, streamEndData.metadata);
@@ -199,7 +199,7 @@ export class WorkspaceStore {
199199
}
200200

201201
// Flush any pending debounced bump before final bump to avoid double-bump
202-
this.flushPendingDebouncedBump(workspaceId);
202+
this.cancelPendingIdleBump(workspaceId);
203203
this.states.bump(workspaceId);
204204
this.dispatchResumeCheck(workspaceId);
205205
this.finalizeUsageStats(workspaceId, streamAbortData.metadata);
@@ -210,7 +210,7 @@ export class WorkspaceStore {
210210
},
211211
"tool-call-delta": (workspaceId, aggregator, data) => {
212212
aggregator.handleToolCallDelta(data as never);
213-
this.debouncedStateBump(workspaceId);
213+
this.scheduleIdleStateBump(workspaceId);
214214
},
215215
"tool-call-end": (workspaceId, aggregator, data) => {
216216
aggregator.handleToolCallEnd(data as never);
@@ -219,7 +219,7 @@ export class WorkspaceStore {
219219
},
220220
"reasoning-delta": (workspaceId, aggregator, data) => {
221221
aggregator.handleReasoningDelta(data as never);
222-
this.debouncedStateBump(workspaceId);
222+
this.scheduleIdleStateBump(workspaceId);
223223
},
224224
"reasoning-end": (workspaceId, aggregator, data) => {
225225
aggregator.handleReasoningEnd(data as never);
@@ -314,36 +314,40 @@ export class WorkspaceStore {
314314
}
315315

316316
/**
317-
* Debounced state bump for high-frequency delta events.
318-
* Coalesces rapid updates (stream-delta, tool-call-delta, reasoning-delta)
319-
* into a single bump per frame (~60fps), reducing React re-renders during streaming.
317+
* Schedule a state bump during browser idle time.
318+
* Instead of updating UI on every delta, wait until the browser has spare capacity.
319+
* This adapts to actual CPU availability - fast machines update more frequently,
320+
* slow machines naturally throttle without dropping data.
320321
*
321-
* Data is always updated immediately in the aggregator - only UI notification is debounced.
322+
* Data is always updated immediately in the aggregator - only UI notification is deferred.
322323
*/
323-
private debouncedStateBump(workspaceId: string): void {
324+
private scheduleIdleStateBump(workspaceId: string): void {
324325
// Skip if already scheduled
325-
if (this.deltaDebounceTimers.has(workspaceId)) {
326+
if (this.deltaIdleHandles.has(workspaceId)) {
326327
return;
327328
}
328329

329-
const timer = setTimeout(() => {
330-
this.deltaDebounceTimers.delete(workspaceId);
331-
this.states.bump(workspaceId);
332-
}, WorkspaceStore.DELTA_DEBOUNCE_MS);
330+
const handle = requestIdleCallback(
331+
() => {
332+
this.deltaIdleHandles.delete(workspaceId);
333+
this.states.bump(workspaceId);
334+
},
335+
{ timeout: 100 } // Force update within 100ms even if browser stays busy
336+
);
333337

334-
this.deltaDebounceTimers.set(workspaceId, timer);
338+
this.deltaIdleHandles.set(workspaceId, handle);
335339
}
336340

337341
/**
338-
* Flush any pending debounced state bump for a workspace (without double-bumping).
342+
* Cancel any pending idle state bump for a workspace.
339343
* Used when immediate state visibility is needed (e.g., stream-end).
340-
* Just clears the timer - the caller will bump() immediately after.
344+
* Just cancels the callback - the caller will bump() immediately after.
341345
*/
342-
private flushPendingDebouncedBump(workspaceId: string): void {
343-
const timer = this.deltaDebounceTimers.get(workspaceId);
344-
if (timer) {
345-
clearTimeout(timer);
346-
this.deltaDebounceTimers.delete(workspaceId);
346+
private cancelPendingIdleBump(workspaceId: string): void {
347+
const handle = this.deltaIdleHandles.get(workspaceId);
348+
if (handle) {
349+
cancelIdleCallback(handle);
350+
this.deltaIdleHandles.delete(workspaceId);
347351
}
348352
}
349353

@@ -787,11 +791,11 @@ export class WorkspaceStore {
787791
// Clean up consumer manager state
788792
this.consumerManager.removeWorkspace(workspaceId);
789793

790-
// Clean up debounce timer to prevent stale callbacks
791-
const timer = this.deltaDebounceTimers.get(workspaceId);
792-
if (timer) {
793-
clearTimeout(timer);
794-
this.deltaDebounceTimers.delete(workspaceId);
794+
// Clean up idle callback to prevent stale callbacks
795+
const handle = this.deltaIdleHandles.get(workspaceId);
796+
if (handle) {
797+
cancelIdleCallback(handle);
798+
this.deltaIdleHandles.delete(workspaceId);
795799
}
796800

797801
// Unsubscribe from IPC

0 commit comments

Comments
 (0)