Skip to content

Commit 767ee59

Browse files
authored
📴 fix: Stop Hidden Code Spinners (#15846)
* fix: keep quote selection updates outside React renders * fix: stop animating hidden code execution spinners * test: guard idle transcripts against continuous animations * test: Assert accepted paste behavior independently of placeholder resize
1 parent 1bea7e4 commit 767ee59

7 files changed

Lines changed: 419 additions & 58 deletions

File tree

client/src/components/Chat/Input/QuoteButton.tsx

Lines changed: 60 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { memo, useRef, useState, useEffect, useCallback, useLayoutEffect } from 'react';
1+
import { memo, useRef, useEffect, useCallback, useLayoutEffect } from 'react';
22
import { createPortal } from 'react-dom';
33
import { TextQuote } from 'lucide-react';
44
import { useSetRecoilState } from 'recoil';
@@ -244,7 +244,7 @@ const resolveTop = (anchor: Anchor, height: number, preferBelow: boolean): numbe
244244
*/
245245
function QuoteButton({ conversationId }: { conversationId: string }) {
246246
const localize = useLocalize();
247-
const [selection, setSelection] = useState<SelectionState | null>(null);
247+
const selectionRef = useRef<SelectionState | null>(null);
248248
const buttonRef = useRef<HTMLButtonElement>(null);
249249
const rangeRef = useRef<Range | null>(null);
250250
const clippersRef = useRef<HTMLElement[]>([]);
@@ -255,6 +255,32 @@ function QuoteButton({ conversationId }: { conversationId: string }) {
255255
const hideRef = useRef<() => void>(() => undefined);
256256
const setQuotes = useSetRecoilState(store.pendingQuotesByConvoId(conversationId));
257257

258+
/** Selection and scroll events must not enter React's commit/selection-restoration
259+
* path. Keep the portal mounted and update only its transient presentation. */
260+
const presentSelection = useCallback((next: SelectionState | null) => {
261+
selectionRef.current = next;
262+
const button = buttonRef.current;
263+
if (!button) {
264+
return;
265+
}
266+
if (!next) {
267+
button.style.display = 'none';
268+
return;
269+
}
270+
const touch = String(next.viaTouch);
271+
if (button.dataset.touch !== touch) {
272+
button.dataset.touch = touch;
273+
}
274+
if (button.style.display === 'none') {
275+
button.style.display = 'inline-flex';
276+
}
277+
const { width, height } = button.getBoundingClientRect();
278+
const maxLeft = Math.max(EDGE_MARGIN, window.innerWidth - width - EDGE_MARGIN);
279+
const left = Math.min(Math.max(anchorCenterX(next.anchor) - width / 2, EDGE_MARGIN), maxLeft);
280+
button.style.top = `${resolveTop(next.anchor, height, next.viaTouch)}px`;
281+
button.style.left = `${left}px`;
282+
}, []);
283+
258284
useEffect(() => {
259285
let settleTimer: ReturnType<typeof setTimeout> | undefined;
260286
let frame = 0;
@@ -279,7 +305,7 @@ function QuoteButton({ conversationId }: { conversationId: string }) {
279305
}
280306
rangeRef.current = null;
281307
clippersRef.current = [];
282-
setSelection(null);
308+
presentSelection(null);
283309
};
284310

285311
const hide = () => {
@@ -299,18 +325,18 @@ function QuoteButton({ conversationId }: { conversationId: string }) {
299325
hide();
300326
return;
301327
}
302-
rangeRef.current = reading.range;
328+
/** Native handle drags can mutate the Selection's Range in place. */
329+
rangeRef.current = reading.range.cloneRange();
303330
clippersRef.current = reading.clippers;
304-
/** Reuse the previous state object when nothing moved so a redundant
305-
* settle pass costs no render. */
306-
setSelection((prev) =>
307-
prev &&
308-
prev.text === reading.text &&
309-
prev.viaTouch === touch &&
310-
sameAnchor(prev.anchor, reading.anchor)
311-
? prev
312-
: { text: reading.text, anchor: reading.anchor, viaTouch: touch },
313-
);
331+
const previous = selectionRef.current;
332+
if (
333+
!previous ||
334+
previous.text !== reading.text ||
335+
previous.viaTouch !== touch ||
336+
!sameAnchor(previous.anchor, reading.anchor)
337+
) {
338+
presentSelection({ text: reading.text, anchor: reading.anchor, viaTouch: touch });
339+
}
314340
};
315341

316342
const handlePointerDown = (event: PointerEvent) => {
@@ -382,9 +408,10 @@ function QuoteButton({ conversationId }: { conversationId: string }) {
382408
hide();
383409
return;
384410
}
385-
setSelection((prev) =>
386-
prev && !sameAnchor(prev.anchor, anchor) ? { ...prev, anchor } : prev,
387-
);
411+
const previous = selectionRef.current;
412+
if (previous && !sameAnchor(previous.anchor, anchor)) {
413+
presentSelection({ ...previous, anchor });
414+
}
388415
};
389416

390417
const scheduleReanchor = () => {
@@ -422,29 +449,17 @@ function QuoteButton({ conversationId }: { conversationId: string }) {
422449
document.removeEventListener('scroll', scheduleReanchor, true);
423450
window.removeEventListener('resize', scheduleReanchor);
424451
};
425-
}, []);
452+
}, [presentSelection]);
426453

427-
/** Clamp using the button's real size so it never lands off-screen. Apply the
428-
* measured layout directly before paint: feeding it back through state adds
429-
* a synchronous render to every selection update and can exhaust React's
430-
* nested-update limit while the browser is still changing the selection. */
454+
/** A parent render (for example a locale change) can change the button size. */
431455
useLayoutEffect(() => {
432-
const button = buttonRef.current;
433-
if (!selection || !button) {
434-
return;
435-
}
436-
const { width, height } = button.getBoundingClientRect();
437-
const maxLeft = Math.max(EDGE_MARGIN, window.innerWidth - width - EDGE_MARGIN);
438-
const left = Math.min(
439-
Math.max(anchorCenterX(selection.anchor) - width / 2, EDGE_MARGIN),
440-
maxLeft,
441-
);
442-
const top = resolveTop(selection.anchor, height, selection.viaTouch);
456+
presentSelection(selectionRef.current);
457+
});
443458

444-
button.style.top = `${top}px`;
445-
button.style.left = `${left}px`;
446-
button.style.visibility = 'visible';
447-
}, [selection]);
459+
useLayoutEffect(() => {
460+
pressedTextRef.current = null;
461+
hideRef.current();
462+
}, [conversationId]);
448463

449464
const commitQuote = useCallback(
450465
(text: string) => {
@@ -454,18 +469,19 @@ function QuoteButton({ conversationId }: { conversationId: string }) {
454469
rangeRef.current = null;
455470
clippersRef.current = [];
456471
pressedTextRef.current = null;
457-
setSelection(null);
472+
presentSelection(null);
458473
window.getSelection()?.removeAllRanges();
459474
document.getElementById(mainTextareaId)?.focus();
460475
},
461-
[setQuotes],
476+
[setQuotes, presentSelection],
462477
);
463478

464479
const addQuote = useCallback(() => {
480+
const selection = selectionRef.current;
465481
if (selection) {
466482
commitQuote(selection.text);
467483
}
468-
}, [selection, commitQuote]);
484+
}, [commitQuote]);
469485

470486
/**
471487
* End a touch press that did not commit. While a press is in flight the
@@ -482,16 +498,12 @@ function QuoteButton({ conversationId }: { conversationId: string }) {
482498
}
483499
}, []);
484500

485-
if (!selection) {
486-
return null;
487-
}
488-
489501
return createPortal(
490502
<button
491503
ref={buttonRef}
492504
type="button"
493505
/** A tap is also the gesture that dismisses a selection, so `click` can
494-
* never be relied on here: the button is already unmounted by the time it
506+
* never be relied on here: the button can be hidden by the time it
495507
* would fire. The excerpt is captured on the press and committed on the
496508
* release instead, which keeps a button's normal escape hatches — drag
497509
* off the button, or have the gesture stolen by a scroll, and nothing is
@@ -501,7 +513,7 @@ function QuoteButton({ conversationId }: { conversationId: string }) {
501513
return;
502514
}
503515
event.preventDefault();
504-
pressedTextRef.current = selection.text;
516+
pressedTextRef.current = selectionRef.current?.text ?? null;
505517
try {
506518
event.currentTarget.setPointerCapture(event.pointerId);
507519
} catch {
@@ -539,13 +551,12 @@ function QuoteButton({ conversationId }: { conversationId: string }) {
539551
style={{
540552
top: 0,
541553
left: 0,
542-
/** Hidden until measured so it never flashes at an unclamped position. */
543-
visibility: 'hidden',
554+
display: 'none',
544555
}}
545556
className={cn(
546557
'fixed z-50 inline-flex items-center gap-1.5 rounded-full border border-border-light bg-surface-secondary text-sm font-medium text-text-primary shadow-lg transition-colors hover:bg-surface-tertiary',
547558
/** Comfortable tap target when the selection came from a finger. */
548-
selection.viaTouch ? 'min-h-11 px-4 py-2.5' : 'px-3 py-1.5',
559+
'px-3 py-1.5 data-[touch=true]:min-h-11 data-[touch=true]:px-4 data-[touch=true]:py-2.5',
549560
)}
550561
>
551562
<TextQuote className="h-4 w-4" aria-hidden="true" />

0 commit comments

Comments
 (0)