Skip to content

Commit d1b3562

Browse files
committed
fix(user-input): translate overlay content instead of scrolling the overlay box
1 parent 9567adf commit d1b3562

2 files changed

Lines changed: 29 additions & 21 deletions

File tree

apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,10 +63,10 @@ export const TEXTAREA_BASE_CLASSES = cn(
6363
)
6464

6565
/**
66-
* Pinned to the textarea's box (`inset-0`) and clipped (`overflow-hidden`) so
67-
* stale paints can never escape the input. Not a scroll container — it mirrors
68-
* the textarea's scroll position via programmatic `scrollTop`, which works on
69-
* `overflow: hidden` boxes.
66+
* Pinned to the textarea's box (`inset-0`) and clipped (`overflow-hidden`).
67+
* The box itself never scrolls — its content is translated to mirror the
68+
* textarea's scroll offset, so the compositor never holds scrolled textures
69+
* for it (a scrolled box can ghost stale paint after its content is cleared).
7070
*/
7171
export const OVERLAY_CLASSES = cn(
7272
FIELD_MIRROR_CLASSES,

apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx

Lines changed: 25 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ export const UserInput = forwardRef<UserInputHandle, UserInputProps>(function Us
171171
})
172172
const valueRef = useRef(value)
173173
valueRef.current = value
174-
const overlayRef = useRef<HTMLDivElement>(null)
174+
const overlayContentRef = useRef<HTMLDivElement>(null)
175175
const plusMenuRef = useRef<PlusMenuHandle>(null)
176176
const skillsMenuRef = useRef<SkillsMenuHandle>(null)
177177

@@ -477,16 +477,26 @@ export const UserInput = forwardRef<UserInputHandle, UserInputProps>(function Us
477477
]
478478
)
479479

480+
/**
481+
* Mirrors the textarea's scroll offset by translating the overlay content.
482+
* The overlay box itself never scrolls — a scrolled box (even with
483+
* `overflow: hidden`) is a compositor scroll container whose stale textures
484+
* can ghost cleared content; a transform always repaints cleanly and never
485+
* clamps, so the mirror tracks the textarea offset exactly.
486+
*/
487+
const syncOverlayScroll = useCallback((scrollTop: number) => {
488+
const content = overlayContentRef.current
489+
if (content) content.style.transform = `translateY(${-scrollTop}px)`
490+
}, [])
491+
480492
useLayoutEffect(() => {
481493
const textarea = textareaRef.current
482494
if (!textarea) return
483495
const maxHeight = isInitialView ? window.innerHeight * 0.3 : MAX_CHAT_TEXTAREA_HEIGHT
484496
textarea.style.height = 'auto'
485497
textarea.style.height = `${Math.min(textarea.scrollHeight, maxHeight)}px`
486-
if (overlayRef.current) {
487-
overlayRef.current.scrollTop = textarea.scrollTop
488-
}
489-
}, [value, isInitialView, textareaRef])
498+
syncOverlayScroll(textarea.scrollTop)
499+
}, [value, isInitialView, textareaRef, syncOverlayScroll])
490500

491501
const handleResourceSelect = useCallback(
492502
(resource: MothershipResource) => {
@@ -1098,12 +1108,9 @@ export const UserInput = forwardRef<UserInputHandle, UserInputProps>(function Us
10981108
(e: React.FormEvent<HTMLTextAreaElement>) => {
10991109
const maxHeight = isInitialView ? window.innerHeight * 0.3 : MAX_CHAT_TEXTAREA_HEIGHT
11001110
autoResizeTextarea(e, maxHeight)
1101-
1102-
if (overlayRef.current) {
1103-
overlayRef.current.scrollTop = (e.target as HTMLTextAreaElement).scrollTop
1104-
}
1111+
syncOverlayScroll((e.target as HTMLTextAreaElement).scrollTop)
11051112
},
1106-
[isInitialView]
1113+
[isInitialView, syncOverlayScroll]
11071114
)
11081115

11091116
const handlePaste = useCallback((e: React.ClipboardEvent<HTMLTextAreaElement>) => {
@@ -1190,11 +1197,12 @@ export const UserInput = forwardRef<UserInputHandle, UserInputProps>(function Us
11901197
filesRef.current.processFiles(dt.files)
11911198
}, [])
11921199

1193-
const handleScroll = useCallback((e: React.UIEvent<HTMLTextAreaElement>) => {
1194-
if (overlayRef.current) {
1195-
overlayRef.current.scrollTop = e.currentTarget.scrollTop
1196-
}
1197-
}, [])
1200+
const handleScroll = useCallback(
1201+
(e: React.UIEvent<HTMLTextAreaElement>) => {
1202+
syncOverlayScroll(e.currentTarget.scrollTop)
1203+
},
1204+
[syncOverlayScroll]
1205+
)
11981206

11991207
/**
12001208
* On copy/cut, write a portable representation of the selection to the
@@ -1332,8 +1340,8 @@ export const UserInput = forwardRef<UserInputHandle, UserInputProps>(function Us
13321340
/>
13331341

13341342
<div className='relative'>
1335-
<div ref={overlayRef} className={OVERLAY_CLASSES} aria-hidden='true'>
1336-
{overlayContent}
1343+
<div className={OVERLAY_CLASSES} aria-hidden='true'>
1344+
<div ref={overlayContentRef}>{overlayContent}</div>
13371345
</div>
13381346

13391347
<textarea

0 commit comments

Comments
 (0)