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:
byGalax
2026-04-22 22:07:05 +02:00
parent eac19823ea
commit 8b9a40f059
@@ -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}
/>