perf(call): actually unfreeze picker while thumbnails stream in
Previous pass moved to JPEG + startTransition but the grid still froze.
Root causes that survived:
1. React.memo was broken — the parent re-created the inline
`onClick={() => onSelect(src.id)}` arrow on every render, so memo's
reference check always triggered a fresh render on every card even
though nothing visible had changed. Fixed by passing `onSelect` as a
stable prop and constructing the click handler inside the memoized
child.
2. 20 data-URL `<img>` sources getting decoded more or less at once gave
the compositor enough work to make scroll feel laggy. `decoding="async"`
punts decode to the browser's image thread; `loading="lazy"` skips it
entirely for cards outside the viewport.
3. Concurrency at 4 was still high enough for Windows GDI BitBlt /
PrintWindow to contend for the desktop compositor — the whole Tauri
window stuttered because the OS-level screen capture was saturating
the graphics pipeline. Dropped to 2 concurrent captures; total load
takes a touch longer but the picker stays interactive throughout.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -62,7 +62,13 @@ export function ScreenSourcePicker({ open, onClose, onStart }: Props) {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const CONCURRENCY = 4;
|
||||
// Concurrency 2 (down from 4): Windows GDI BitBlt / PrintWindow on
|
||||
// multiple source windows contends for the desktop compositor and
|
||||
// the whole Tauri window stutters while 4+ captures are in flight.
|
||||
// 2 in parallel keeps the compositor breathing and the picker grid
|
||||
// stays scrollable. Total load time goes up marginally since most
|
||||
// individual captures are GDI-bound, not thread-bound.
|
||||
const CONCURRENCY = 2;
|
||||
void (async () => {
|
||||
const list = await listScreenSources();
|
||||
if (cancelled) return;
|
||||
@@ -310,7 +316,7 @@ function SourceSection({
|
||||
key={src.id}
|
||||
source={src}
|
||||
selected={selectedId === src.id}
|
||||
onClick={() => onSelect(src.id)}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -322,20 +328,25 @@ function SourceSection({
|
||||
// Keeps re-render work proportional to the number of updates instead of
|
||||
// "whole grid on every update" — which was the main reason scrolling felt
|
||||
// frozen during the initial thumbnail fan-in.
|
||||
//
|
||||
// The parent passes `onSelect(id)` rather than an inline `onClick`-arrow
|
||||
// so the callback reference stays stable across renders; otherwise
|
||||
// React.memo would always see a fresh function prop and re-render every
|
||||
// card on every parent update.
|
||||
const SourceCard = memo(function SourceCard({
|
||||
source,
|
||||
selected,
|
||||
onClick,
|
||||
onSelect,
|
||||
}: {
|
||||
source: ScreenSource;
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
onSelect: (id: string) => void;
|
||||
}) {
|
||||
const thumb = thumbnailDataUrl(source);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
onClick={() => onSelect(source.id)}
|
||||
aria-pressed={selected}
|
||||
title={source.name}
|
||||
className={
|
||||
@@ -347,9 +358,16 @@ const SourceCard = memo(function SourceCard({
|
||||
>
|
||||
<div className="relative aspect-video w-full overflow-hidden bg-black">
|
||||
{thumb ? (
|
||||
// decoding="async" keeps large base64 images off the main-thread
|
||||
// paint step; loading="lazy" means cards outside the viewport
|
||||
// don't ask the browser to decode until the user scrolls to
|
||||
// them. Together they stop the grid from freezing when 20
|
||||
// thumbnails land in quick succession.
|
||||
<img
|
||||
src={thumb}
|
||||
alt=""
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
className="h-full w-full object-contain transition group-hover:brightness-110"
|
||||
draggable={false}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user