Fix composer scrollbar resize loop (#1050)

This commit is contained in:
Mohamed Boudra
2026-05-16 19:31:24 +08:00
committed by GitHub
parent f4a4e0c25c
commit 1c38ffe0a9
3 changed files with 69 additions and 29 deletions
@@ -98,13 +98,19 @@ export function useComposerHeightMirror({
useLayoutEffect(() => { useLayoutEffect(() => {
measure(); measure();
}, [value, measure]); }, [maxHeight, minHeight, value, measure]);
useEffect(() => { useEffect(() => {
const source = textareaRef.current; const source = textareaRef.current;
if (!source || !(source instanceof HTMLElement)) return; if (!source || !(source instanceof HTMLElement)) return;
if (typeof ResizeObserver === "undefined") return; if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(() => measure()); let previousWidth = source.clientWidth;
const observer = new ResizeObserver(() => {
const nextWidth = source.clientWidth;
if (Math.abs(nextWidth - previousWidth) < 1) return;
previousWidth = nextWidth;
measure();
});
observer.observe(source); observer.observe(source);
return () => observer.disconnect(); return () => observer.disconnect();
}, [textareaRef, measure]); }, [textareaRef, measure]);
+24 -11
View File
@@ -3,6 +3,7 @@ import {
Text, Text,
TextInput, TextInput,
ActivityIndicator, ActivityIndicator,
useWindowDimensions,
NativeSyntheticEvent, NativeSyntheticEvent,
TextInputContentSizeChangeEventData, TextInputContentSizeChangeEventData,
TextInputKeyPressEventData, TextInputKeyPressEventData,
@@ -144,7 +145,8 @@ export interface MessageInputRef {
const MIN_INPUT_HEIGHT_MOBILE = 30; const MIN_INPUT_HEIGHT_MOBILE = 30;
const MIN_INPUT_HEIGHT_DESKTOP = 46; const MIN_INPUT_HEIGHT_DESKTOP = 46;
const MAX_INPUT_HEIGHT = 160; const DEFAULT_MAX_INPUT_HEIGHT = 160;
const MAX_INPUT_VIEWPORT_RATIO = 0.5;
const MIN_INPUT_HEIGHT = isWeb ? MIN_INPUT_HEIGHT_DESKTOP : MIN_INPUT_HEIGHT_MOBILE; const MIN_INPUT_HEIGHT = isWeb ? MIN_INPUT_HEIGHT_DESKTOP : MIN_INPUT_HEIGHT_MOBILE;
type WebTextInputKeyPressEvent = NativeSyntheticEvent< type WebTextInputKeyPressEvent = NativeSyntheticEvent<
@@ -983,17 +985,22 @@ function computeIsDictationStartEnabled(
return (isReadyForDictation ?? isConnected) && !disabled; return (isReadyForDictation ?? isConnected) && !disabled;
} }
function computeTextInputHeightStyle(inputHeight: number) { function resolveMaxInputHeight(windowHeight: number): number {
if (!Number.isFinite(windowHeight) || windowHeight <= 0) return DEFAULT_MAX_INPUT_HEIGHT;
return Math.max(DEFAULT_MAX_INPUT_HEIGHT, Math.floor(windowHeight * MAX_INPUT_VIEWPORT_RATIO));
}
function computeTextInputHeightStyle(inputHeight: number, maxInputHeight: number) {
if (isWeb) { if (isWeb) {
return { return {
height: inputHeight, height: inputHeight,
minHeight: MIN_INPUT_HEIGHT, minHeight: MIN_INPUT_HEIGHT,
maxHeight: MAX_INPUT_HEIGHT, maxHeight: maxInputHeight,
}; };
} }
return { return {
minHeight: MIN_INPUT_HEIGHT, minHeight: MIN_INPUT_HEIGHT,
maxHeight: MAX_INPUT_HEIGHT, maxHeight: maxInputHeight,
}; };
} }
@@ -1191,6 +1198,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
attachmentSlot, attachmentSlot,
} = resolveMessageInputProps(props); } = resolveMessageInputProps(props);
const isCompact = useIsCompactFormFactor(); const isCompact = useIsCompactFormFactor();
const { height: windowHeight } = useWindowDimensions();
const maxInputHeight = resolveMaxInputHeight(windowHeight);
const buttonIconSize = isWeb ? ICON_SIZE.md : ICON_SIZE.lg; const buttonIconSize = isWeb ? ICON_SIZE.md : ICON_SIZE.lg;
const toast = useToast(); const toast = useToast();
const voice = useVoiceOptional(); const voice = useVoiceOptional();
@@ -1522,7 +1531,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
}, [getWebTextArea]); }, [getWebTextArea]);
const inputScrollbar = useWebElementScrollbar(webTextareaRef, { const inputScrollbar = useWebElementScrollbar(webTextareaRef, {
enabled: isWeb && inputHeight >= MAX_INPUT_HEIGHT, enabled: isWeb,
}); });
usePasteImagesEffect({ usePasteImagesEffect({
@@ -1536,20 +1545,24 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const setBoundedInputHeight = useCallback( const setBoundedInputHeight = useCallback(
(nextHeight: number) => { (nextHeight: number) => {
const bounded = Math.max(MIN_INPUT_HEIGHT, Math.min(MAX_INPUT_HEIGHT, nextHeight)); const bounded = Math.max(MIN_INPUT_HEIGHT, Math.min(maxInputHeight, nextHeight));
if (Math.abs(inputHeightRef.current - bounded) < 1) return; if (Math.abs(inputHeightRef.current - bounded) < 1) return;
inputHeightRef.current = bounded; inputHeightRef.current = bounded;
setInputHeight(bounded); setInputHeight(bounded);
onHeightChange?.(bounded); onHeightChange?.(bounded);
}, },
[onHeightChange], [maxInputHeight, onHeightChange],
); );
useEffect(() => {
setBoundedInputHeight(inputHeightRef.current);
}, [setBoundedInputHeight]);
useComposerHeightMirror({ useComposerHeightMirror({
value, value,
textareaRef: webTextareaRef, textareaRef: webTextareaRef,
minHeight: MIN_INPUT_HEIGHT, minHeight: MIN_INPUT_HEIGHT,
maxHeight: MAX_INPUT_HEIGHT, maxHeight: maxInputHeight,
onHeight: setBoundedInputHeight, onHeight: setBoundedInputHeight,
}); });
@@ -1674,8 +1687,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
[inputWrapperStyle, inputAnimatedStyle], [inputWrapperStyle, inputAnimatedStyle],
); );
const textInputStyle = useMemo( const textInputStyle = useMemo(
() => [styles.textInput, computeTextInputHeightStyle(inputHeight)], () => [styles.textInput, computeTextInputHeightStyle(inputHeight, maxInputHeight)],
[inputHeight], [inputHeight, maxInputHeight],
); );
const sendButtonCombinedStyle = useMemo( const sendButtonCombinedStyle = useMemo(
() => [styles.sendButton, isSendButtonDisabled && styles.buttonDisabled], () => [styles.sendButton, isSendButtonDisabled && styles.buttonDisabled],
@@ -1727,7 +1740,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
onBlur={handleInputBlur} onBlur={handleInputBlur}
style={textInputStyle} style={textInputStyle}
multiline multiline
scrollEnabled={isWeb ? inputHeight >= MAX_INPUT_HEIGHT : true} scrollEnabled={isWeb ? inputHeight >= maxInputHeight : true}
onContentSizeChange={handleContentSizeChange} onContentSizeChange={handleContentSizeChange}
editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled} editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled}
onKeyPress={shouldHandleWebKeyPress ? handleDesktopKeyPress : undefined} onKeyPress={shouldHandleWebKeyPress ? handleDesktopKeyPress : undefined}
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState, type ReactNode, type RefObject } from "react"; import { useCallback, useLayoutEffect, useState, type ReactNode, type RefObject } from "react";
import { import {
type FlatList, type FlatList,
type LayoutChangeEvent, type LayoutChangeEvent,
@@ -21,8 +21,25 @@ function ensureHideScrollbarStyle(): void {
if (document.getElementById(HIDE_SCROLLBAR_STYLE_ID)) return; if (document.getElementById(HIDE_SCROLLBAR_STYLE_ID)) return;
const style = document.createElement("style"); const style = document.createElement("style");
style.id = HIDE_SCROLLBAR_STYLE_ID; style.id = HIDE_SCROLLBAR_STYLE_ID;
style.textContent = style.textContent = `
"[data-hide-scrollbar]::-webkit-scrollbar { display: none; width: 0; height: 0; }"; [data-hide-scrollbar] {
scrollbar-width: none;
-ms-overflow-style: none;
scrollbar-gutter: auto;
}
[data-hide-scrollbar]::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
}
[data-hide-scrollbar]::-webkit-scrollbar-button {
display: none;
width: 0;
height: 0;
}
`;
document.head.appendChild(style); document.head.appendChild(style);
} }
@@ -54,18 +71,25 @@ export function useWebElementScrollbar(
contentSize: 0, contentSize: 0,
}); });
useEffect(() => { useLayoutEffect(() => {
if (!enabled) return; if (!enabled) return;
const element = elementRef.current; const element = elementRef.current;
if (!element) return; if (!element) return;
type ScrollbarStyle = CSSStyleDeclaration & {
scrollbarWidth: string;
msOverflowStyle: string;
scrollbarGutter: string;
};
const style = element.style as ScrollbarStyle;
const previousScrollbarWidth = style.scrollbarWidth;
const previousMsOverflowStyle = style.msOverflowStyle;
const previousScrollbarGutter = style.scrollbarGutter;
element.setAttribute("data-hide-scrollbar", ""); element.setAttribute("data-hide-scrollbar", "");
( style.scrollbarWidth = "none";
element.style as CSSStyleDeclaration & { scrollbarWidth: string; msOverflowStyle: string } style.msOverflowStyle = "none";
).scrollbarWidth = "none"; style.scrollbarGutter = "auto";
(
element.style as CSSStyleDeclaration & { scrollbarWidth: string; msOverflowStyle: string }
).msOverflowStyle = "none";
ensureHideScrollbarStyle(); ensureHideScrollbarStyle();
function update() { function update() {
@@ -94,12 +118,9 @@ export function useWebElementScrollbar(
element.removeEventListener("scroll", update); element.removeEventListener("scroll", update);
resizeObserver.disconnect(); resizeObserver.disconnect();
element.removeAttribute("data-hide-scrollbar"); element.removeAttribute("data-hide-scrollbar");
( style.scrollbarWidth = previousScrollbarWidth;
element.style as CSSStyleDeclaration & { scrollbarWidth: string; msOverflowStyle: string } style.msOverflowStyle = previousMsOverflowStyle;
).scrollbarWidth = ""; style.scrollbarGutter = previousScrollbarGutter;
(
element.style as CSSStyleDeclaration & { scrollbarWidth: string; msOverflowStyle: string }
).msOverflowStyle = "";
}; };
}, [contentRef, elementRef, enabled]); }, [contentRef, elementRef, enabled]);