From 3a72b8d3c4a6f8901e1b73333971d07ec9f13345 Mon Sep 17 00:00:00 2001 From: hampus-fluxer Date: Sun, 4 Jan 2026 15:33:34 +0100 Subject: [PATCH] fix(app): fix perf issues and bugs with textarea autosizing (#20) --- .../components/channel/ChannelTextarea.tsx | 76 ++++++++- .../channel/EditingMessageInput.tsx | 1 + .../components/channel/Messages.module.css | 4 + .../src/components/channel/Messages.tsx | 1 + .../channel/textarea/TextareaInput.module.css | 8 +- .../channel/textarea/TextareaInputField.tsx | 54 +----- fluxer_app/src/lib/TextareaAutosize.tsx | 161 +++++++++++++++--- 7 files changed, 230 insertions(+), 75 deletions(-) diff --git a/fluxer_app/src/components/channel/ChannelTextarea.tsx b/fluxer_app/src/components/channel/ChannelTextarea.tsx index 1a904f79..20306ef7 100644 --- a/fluxer_app/src/components/channel/ChannelTextarea.tsx +++ b/fluxer_app/src/components/channel/ChannelTextarea.tsx @@ -106,6 +106,13 @@ import * as PlaceholderUtils from '~/utils/PlaceholderUtils'; import wrapperStyles from './textarea/InputWrapper.module.css'; import styles from './textarea/TextareaInput.module.css'; +function readBorderBoxBlockSize(entry: ResizeObserverEntry): number { + const bbs: any = (entry as any).borderBoxSize; + if (Array.isArray(bbs) && bbs[0] && typeof bbs[0].blockSize === 'number') return bbs[0].blockSize; + if (bbs && typeof bbs.blockSize === 'number') return bbs.blockSize; + return (entry.target as HTMLElement).getBoundingClientRect().height; +} + const ChannelTextareaContent = observer( ({ channel, @@ -123,7 +130,6 @@ const ChannelTextareaContent = observer( const [isInputAreaFocused, setIsInputAreaFocused] = React.useState(false); const [value, setValue] = React.useState(''); const [showAllButtons, setShowAllButtons] = React.useState(true); - const [_textareaHeight, setTextareaHeight] = React.useState(0); const [containerWidth, setContainerWidth] = React.useState(0); const [pendingMentionConfirmation, setPendingMentionConfirmation] = React.useState( null, @@ -139,6 +145,64 @@ const ChannelTextareaContent = observer( const scrollerRef = React.useRef(null); useMarkdownKeybinds(isFocused); + const textareaHeightRef = React.useRef(0); + const handleTextareaHeightChange = React.useCallback((height: number) => { + textareaHeightRef.current = height; + }, []); + + const inputBoxHeightRef = React.useRef(null); + const pendingLayoutDeltaRef = React.useRef(0); + const flushScheduledRef = React.useRef(false); + + React.useLayoutEffect(() => { + const el = containerRef.current; + if (!el || typeof ResizeObserver === 'undefined') return; + + inputBoxHeightRef.current = null; + pendingLayoutDeltaRef.current = 0; + flushScheduledRef.current = false; + + const flush = () => { + flushScheduledRef.current = false; + const delta = pendingLayoutDeltaRef.current; + pendingLayoutDeltaRef.current = 0; + if (!delta) return; + if (delta <= 0) return; + + ComponentDispatch.dispatch('LAYOUT_RESIZED', { + channelId: channel.id, + heightDelta: delta, + }); + }; + + const ro = new ResizeObserver((entries) => { + const entry = entries[0]; + if (!entry) return; + + const nextHeight = Math.round(readBorderBoxBlockSize(entry)); + const prevHeight = inputBoxHeightRef.current; + + if (prevHeight == null) { + inputBoxHeightRef.current = nextHeight; + return; + } + + const delta = nextHeight - prevHeight; + if (!delta) return; + + inputBoxHeightRef.current = nextHeight; + pendingLayoutDeltaRef.current += delta; + + if (!flushScheduledRef.current) { + flushScheduledRef.current = true; + queueMicrotask(flush); + } + }); + + ro.observe(el); + return () => ro.disconnect(); + }, [channel.id]); + const showGiftButton = AccessibilityStore.showGiftButton; const showGifButton = AccessibilityStore.showGifButton; const showMemesButton = AccessibilityStore.showMemesButton; @@ -653,9 +717,15 @@ const ChannelTextareaContent = observer( React.useLayoutEffect(() => { if (!containerRef.current) return; + let lastWidth = -1; + const checkButtonVisibility = () => { if (!containerRef.current) return; const containerWidthLocal = containerRef.current.offsetWidth; + + if (containerWidthLocal === lastWidth) return; + lastWidth = containerWidthLocal; + const shouldShowAll = containerWidthLocal > 500; setShowAllButtons(shouldShowAll); setContainerWidth(containerWidthLocal); @@ -765,12 +835,12 @@ const ChannelTextareaContent = observer(
; - scrollerRef?: React.RefObject | null; shouldStickToBottomRef?: React.MutableRefObject; isFocused?: boolean; isAutocompleteAttached: boolean; @@ -60,7 +59,6 @@ export const TextareaInputField = React.forwardRef { - const lastHeightRef = React.useRef(0); - useTextareaAutofocus(textareaRef, isMobile, !disabled); const handleKeyDown = (event: React.KeyboardEvent) => { @@ -124,49 +119,6 @@ export const TextareaInputField = React.forwardRef { - const clampToSingleRow = () => { - if (value.length > 0 || !textareaRef.current || !meta?.rowHeight) { - return height; - } - - const style = window.getComputedStyle(textareaRef.current); - const padding = (parseFloat(style.paddingTop || '0') || 0) + (parseFloat(style.paddingBottom || '0') || 0); - const border = - (parseFloat(style.borderTopWidth || '0') || 0) + (parseFloat(style.borderBottomWidth || '0') || 0); - const singleRowHeight = meta.rowHeight + padding + border; - - if (!Number.isFinite(singleRowHeight) || singleRowHeight <= 0 || singleRowHeight >= height) { - return height; - } - - textareaRef.current.style.setProperty('height', `${singleRowHeight}px`, 'important'); - return singleRowHeight; - }; - - const adjustedHeight = clampToSingleRow(); - - if (adjustedHeight === lastHeightRef.current) { - return; - } - - const heightDelta = adjustedHeight - lastHeightRef.current; - lastHeightRef.current = adjustedHeight; - - const distanceFromBottom = scrollerRef?.current?.getDistanceFromBottom?.() ?? 0; - const shouldStickToBottom = shouldStickToBottomRef?.current ?? distanceFromBottom <= 8; - - onHeightChange(adjustedHeight); - - if (shouldStickToBottom) { - scrollerRef?.current?.scrollToBottom({animate: false}); - } - - queueMicrotask(() => { - ComponentDispatch.dispatch('LAYOUT_RESIZED', {heightDelta}); - }); - }; - return ( onChange(event.target.value)} onFocus={onFocus} - onHeightChange={handleHeightChange} + onHeightChange={(h) => onHeightChange(h)} onKeyDown={handleKeyDown} placeholder={placeholder} ref={textareaRef} diff --git a/fluxer_app/src/lib/TextareaAutosize.tsx b/fluxer_app/src/lib/TextareaAutosize.tsx index 9fd77523..cab96177 100644 --- a/fluxer_app/src/lib/TextareaAutosize.tsx +++ b/fluxer_app/src/lib/TextareaAutosize.tsx @@ -51,16 +51,96 @@ function computeRowConstraints(el: HTMLTextAreaElement, minRows?: number, maxRow }; } +function supportsFieldSizingContent(): boolean { + try { + return typeof CSS !== 'undefined' && typeof CSS.supports === 'function' && CSS.supports('field-sizing: content'); + } catch { + return false; + } +} + +function normalizeValueForMeasurement(value: string): string { + if (!value.includes('\n')) return value; + const parts = value.split('\n'); + for (let i = 0; i < parts.length; i++) { + if (parts[i] === '') parts[i] = '\u200b'; + } + return parts.join('\n'); +} + +function ensureMeasureEl(): HTMLTextAreaElement | null { + if (typeof document === 'undefined') return null; + const el = document.createElement('textarea'); + el.setAttribute('aria-hidden', 'true'); + el.tabIndex = -1; + el.style.position = 'absolute'; + el.style.top = '0'; + el.style.left = '-9999px'; + el.style.height = '0px'; + el.style.overflow = 'hidden'; + el.style.visibility = 'hidden'; + el.style.pointerEvents = 'none'; + el.style.zIndex = '-1'; + document.body.appendChild(el); + return el; +} + +function syncMeasureStyles(target: HTMLTextAreaElement, measure: HTMLTextAreaElement) { + const cs = window.getComputedStyle(target); + + measure.style.boxSizing = cs.boxSizing; + measure.style.width = `${target.getBoundingClientRect().width}px`; + + measure.style.font = cs.font; + measure.style.fontFamily = cs.fontFamily; + measure.style.fontSize = cs.fontSize; + measure.style.fontWeight = cs.fontWeight; + measure.style.fontStyle = cs.fontStyle; + measure.style.letterSpacing = cs.letterSpacing; + measure.style.textTransform = cs.textTransform; + measure.style.textRendering = cs.textRendering as any; + + measure.style.lineHeight = cs.lineHeight; + measure.style.whiteSpace = cs.whiteSpace; + measure.style.wordBreak = cs.wordBreak; + measure.style.overflowWrap = (cs as any).overflowWrap ?? 'normal'; + measure.style.tabSize = (cs as any).tabSize ?? '8'; + + measure.style.paddingTop = cs.paddingTop; + measure.style.paddingBottom = cs.paddingBottom; + measure.style.paddingLeft = cs.paddingLeft; + measure.style.paddingRight = cs.paddingRight; + + measure.style.borderTopWidth = cs.borderTopWidth; + measure.style.borderBottomWidth = cs.borderBottomWidth; + measure.style.borderLeftWidth = cs.borderLeftWidth; + measure.style.borderRightWidth = cs.borderRightWidth; + measure.style.borderTopStyle = cs.borderTopStyle; + measure.style.borderBottomStyle = cs.borderBottomStyle; + measure.style.borderLeftStyle = cs.borderLeftStyle; + measure.style.borderRightStyle = cs.borderRightStyle; + measure.style.borderTopColor = cs.borderTopColor; + measure.style.borderBottomColor = cs.borderBottomColor; + measure.style.borderLeftColor = cs.borderLeftColor; + measure.style.borderRightColor = cs.borderRightColor; + + measure.style.borderRadius = cs.borderRadius; +} + export const TextareaAutosize = React.forwardRef((props, forwardedRef) => { const {minRows: minRowsProp, maxRows, style, onHeightChange, rows, onInput, ...rest} = props; const resolvedRows = rows ?? 1; const minRows = minRowsProp ?? (typeof resolvedRows === 'number' ? resolvedRows : undefined); + const nativeFieldSizing = supportsFieldSizingContent(); + const elRef = React.useRef(null); + const measureRef = React.useRef(null); + const onHeightChangeRef = React.useRef(onHeightChange); const lastWidthRef = React.useRef(null); - const lastHeightRef = React.useRef(null); + const lastEmittedHeightRef = React.useRef(null); const resizeScheduledRef = React.useRef(false); const setRef = React.useCallback( @@ -76,49 +156,84 @@ export const TextareaAutosize = React.forwardRef { + if (nativeFieldSizing) return; + const measure = ensureMeasureEl(); + measureRef.current = measure; + return () => { + measureRef.current?.remove(); + measureRef.current = null; + }; + }, [nativeFieldSizing]); + + const emitHeightIfChanged = React.useCallback(() => { + const el = elRef.current; + if (!el) return; + + const cs = window.getComputedStyle(el); + const lineHeight = getLineHeight(cs); + const height = Math.round(el.getBoundingClientRect().height); + + if (lastEmittedHeightRef.current !== height) { + lastEmittedHeightRef.current = height; + onHeightChangeRef.current?.(height, {rowHeight: lineHeight}); + } + }, []); + React.useLayoutEffect(() => { const el = elRef.current; if (!el || (minRows == null && maxRows == null)) return; const {minHeight, maxHeight} = computeRowConstraints(el, minRows, maxRows); - if (minHeight != null) { - el.style.minHeight = `${minHeight}px`; - } - if (maxHeight != null) { - el.style.maxHeight = `${maxHeight}px`; - } + if (minHeight != null) el.style.minHeight = `${minHeight}px`; + if (maxHeight != null) el.style.maxHeight = `${maxHeight}px`; }, [minRows, maxRows]); const resize = React.useCallback(() => { const el = elRef.current; if (!el) return; + if (nativeFieldSizing) { + emitHeightIfChanged(); + return; + } + + const measure = measureRef.current; + if (!measure) return; + const cs = window.getComputedStyle(el); const {minHeight, maxHeight, lineHeight} = computeRowConstraints(el, minRows, maxRows); const borderBlock = getNumber(cs.borderTopWidth) + getNumber(cs.borderBottomWidth); const isBorderBox = cs.boxSizing === 'border-box'; - el.style.height = 'auto'; + syncMeasureStyles(el, measure); - let nextHeight = el.scrollHeight + (isBorderBox ? borderBlock : 0); + const measuredValue = normalizeValueForMeasurement(el.value); + if (measure.value !== measuredValue) { + measure.value = measuredValue; + } + + let nextHeight = measure.scrollHeight + (isBorderBox ? borderBlock : 0); if (minHeight != null) nextHeight = Math.max(nextHeight, minHeight); if (maxHeight != null) nextHeight = Math.min(nextHeight, maxHeight); - const heightPx = `${nextHeight}px`; + const heightPx = `${Math.round(nextHeight)}px`; if (el.style.height !== heightPx) { el.style.height = heightPx; } - if (lastHeightRef.current !== nextHeight) { - lastHeightRef.current = nextHeight; - onHeightChangeRef.current?.(nextHeight, {rowHeight: lineHeight}); + const emittedHeight = Math.round(nextHeight); + if (lastEmittedHeightRef.current !== emittedHeight) { + lastEmittedHeightRef.current = emittedHeight; + onHeightChangeRef.current?.(emittedHeight, {rowHeight: lineHeight}); } - }, [maxRows, minRows]); + }, [emitHeightIfChanged, maxRows, minRows, nativeFieldSizing]); const scheduleResize = React.useCallback(() => { if (resizeScheduledRef.current) return; resizeScheduledRef.current = true; + requestAnimationFrame(() => { resizeScheduledRef.current = false; resize(); @@ -133,16 +248,22 @@ export const TextareaAutosize = React.forwardRef ro.disconnect(); - }, [scheduleResize]); + }, [emitHeightIfChanged, nativeFieldSizing, scheduleResize]); const computedStyle = React.useMemo( (): React.CSSProperties => ({ @@ -154,15 +275,15 @@ export const TextareaAutosize = React.forwardRef) => { - resize(); + scheduleResize(); onInput?.(event); }, - [onInput, resize], + [onInput, scheduleResize], ); React.useLayoutEffect(() => { - resize(); - }, [resize, props.value, props.defaultValue, rows]); + scheduleResize(); + }, [scheduleResize, props.value, props.defaultValue, rows, minRows, maxRows, nativeFieldSizing]); return