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;
|
return;
|
||||||
}
|
}
|
||||||
let cancelled = false;
|
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 () => {
|
void (async () => {
|
||||||
const list = await listScreenSources();
|
const list = await listScreenSources();
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
@@ -310,7 +316,7 @@ function SourceSection({
|
|||||||
key={src.id}
|
key={src.id}
|
||||||
source={src}
|
source={src}
|
||||||
selected={selectedId === src.id}
|
selected={selectedId === src.id}
|
||||||
onClick={() => onSelect(src.id)}
|
onSelect={onSelect}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -322,20 +328,25 @@ function SourceSection({
|
|||||||
// Keeps re-render work proportional to the number of updates instead of
|
// Keeps re-render work proportional to the number of updates instead of
|
||||||
// "whole grid on every update" — which was the main reason scrolling felt
|
// "whole grid on every update" — which was the main reason scrolling felt
|
||||||
// frozen during the initial thumbnail fan-in.
|
// 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({
|
const SourceCard = memo(function SourceCard({
|
||||||
source,
|
source,
|
||||||
selected,
|
selected,
|
||||||
onClick,
|
onSelect,
|
||||||
}: {
|
}: {
|
||||||
source: ScreenSource;
|
source: ScreenSource;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
onClick: () => void;
|
onSelect: (id: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const thumb = thumbnailDataUrl(source);
|
const thumb = thumbnailDataUrl(source);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClick}
|
onClick={() => onSelect(source.id)}
|
||||||
aria-pressed={selected}
|
aria-pressed={selected}
|
||||||
title={source.name}
|
title={source.name}
|
||||||
className={
|
className={
|
||||||
@@ -347,9 +358,16 @@ const SourceCard = memo(function SourceCard({
|
|||||||
>
|
>
|
||||||
<div className="relative aspect-video w-full overflow-hidden bg-black">
|
<div className="relative aspect-video w-full overflow-hidden bg-black">
|
||||||
{thumb ? (
|
{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
|
<img
|
||||||
src={thumb}
|
src={thumb}
|
||||||
alt=""
|
alt=""
|
||||||
|
decoding="async"
|
||||||
|
loading="lazy"
|
||||||
className="h-full w-full object-contain transition group-hover:brightness-110"
|
className="h-full w-full object-contain transition group-hover:brightness-110"
|
||||||
draggable={false}
|
draggable={false}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user