Fix #12869: Prevent text area from snapping back after manual resize#14560
Fix #12869: Prevent text area from snapping back after manual resize#14560joanagcardosoo wants to merge 1 commit intostreamlit:developfrom
Conversation
…l resize When a st.text_area uses height="content", it automatically adjusts its height. However, if a user manually resized the box by dragging the corner and then typed new text, the box would abruptly snap back to the auto-calculated content height, ignoring the manual resize. This commit fixes the issue by introducing a ResizeObserver to track manual user resizing. When manual resizing is detected, the text value is temporarily removed from the auto-expand hook dependencies. This stops the component from shrinking back on every keystroke. Additionally, if the user types or pastes text that exceeds the new manual height, the manual height is cleared and control is returned to the auto-expand logic. The height CSS property is also updated to respect the manually dragged size by returning undefined when needed.
|
Thanks for contributing to Streamlit! 🎈 Please make sure you have read our Contributing Guide. You can find additional information about Streamlit development in the wiki. The review process:
We're receiving many contributions and have limited review bandwidth — please expect some delay. We appreciate your patience! 🙏 |
✅ Snyk checks have passed. No issues have been found so far.
💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse. |
| let lastHeight = textarea.offsetHeight | ||
|
|
||
| const observer = new ResizeObserver(entries => { | ||
| // If we are programatically resetting the height (e.g. huge paste), ignore this event! | ||
| if (ignoreObserver.current) return | ||
|
|
||
| const newHeight = entries[0].target.clientHeight | ||
| const isAutoExpanding = Math.abs(newHeight - autoHeightRef.current) <= 2 | ||
|
|
||
| // If the resize didn't match the auto-expand hook calculation, we assume the user dragged the corner | ||
| if (!isAutoExpanding && Math.abs(newHeight - lastHeight) > 2) { | ||
| setUserResized(true) | ||
| } | ||
|
|
||
| lastHeight = newHeight |
There was a problem hiding this comment.
Bug: Inconsistent height measurements cause incorrect manual resize detection. Line 128 initializes lastHeight with offsetHeight, but line 134 uses clientHeight for newHeight. These measurements differ (offsetHeight includes borders, clientHeight doesn't). Line 142 then assigns clientHeight to lastHeight, creating further inconsistency. On subsequent resize events, the comparison on line 138 will compare clientHeight values with what was originally offsetHeight, causing false positives for manual resizing.
Fix: Use consistent height measurement throughout:
let lastHeight = textarea.clientHeight
const observer = new ResizeObserver(entries => {
if (ignoreObserver.current) return
const newHeight = entries[0].target.clientHeight
const isAutoExpanding = Math.abs(newHeight - autoHeightRef.current) <= 2
if (!isAutoExpanding && Math.abs(newHeight - lastHeight) > 2) {
setUserResized(true)
}
lastHeight = newHeight
})| let lastHeight = textarea.offsetHeight | |
| const observer = new ResizeObserver(entries => { | |
| // If we are programatically resetting the height (e.g. huge paste), ignore this event! | |
| if (ignoreObserver.current) return | |
| const newHeight = entries[0].target.clientHeight | |
| const isAutoExpanding = Math.abs(newHeight - autoHeightRef.current) <= 2 | |
| // If the resize didn't match the auto-expand hook calculation, we assume the user dragged the corner | |
| if (!isAutoExpanding && Math.abs(newHeight - lastHeight) > 2) { | |
| setUserResized(true) | |
| } | |
| lastHeight = newHeight | |
| let lastHeight = textarea.clientHeight | |
| const observer = new ResizeObserver(entries => { | |
| // If we are programatically resetting the height (e.g. huge paste), ignore this event! | |
| if (ignoreObserver.current) return | |
| const newHeight = entries[0].target.clientHeight | |
| const isAutoExpanding = Math.abs(newHeight - autoHeightRef.current) <= 2 | |
| // If the resize didn't match the auto-expand hook calculation, we assume the user dragged the corner | |
| if (!isAutoExpanding && Math.abs(newHeight - lastHeight) > 2) { | |
| setUserResized(true) | |
| } | |
| lastHeight = newHeight |
Spotted by Graphite
Is this helpful? React 👍 or 👎 to let us know.
There was a problem hiding this comment.
Pull request overview
Fixes a UX issue in the frontend TextArea widget where st.text_area(height="content") would snap back to the auto-calculated content height after the user manually resized the textarea and then typed more text.
Changes:
- Add a
ResizeObserver-based mechanism to detect manual textarea resizing and track it via component state. - Adjust
useTextInputAutoExpanddependencies to temporarily stop auto-resizing while a manual resize is in effect, and restore auto-resize when content overflows the manual height. - Update the applied
heightstyle behavior to avoid overriding user-driven sizing.
| } = useTextInputAutoExpand({ | ||
| textareaRef, | ||
| // Recalculate height when placeholder or committed value changes | ||
| dependencies: [element.placeholder, value], | ||
| // If the user manually resized, we hide the value from dependencies (passing null) | ||
| // to prevent the hook from shrinking the box back to text-size on every keystroke. | ||
| dependencies: [element.placeholder, userResized ? null : value], | ||
| }) |
There was a problem hiding this comment.
The new manual resize / auto-expand interaction isn’t covered by tests in TextArea.test.tsx. Adding a test that simulates a manual resize (e.g. by setting textarea.style.height and firing a ResizeObserver callback), then typing to ensure the height doesn’t snap back, would help prevent regressions.
| // Ref to temporarily blindfold the ResizeObserver during large programatic height shifts | ||
| const ignoreObserver = useRef(false) |
There was a problem hiding this comment.
Typo in comment: "programatic" → "programmatic".
| // If we are programatically resetting the height (e.g. huge paste), ignore this event! | ||
| if (ignoreObserver.current) return |
There was a problem hiding this comment.
Typo in comment: "programatically" → "programmatically".
| height: | ||
| isAutoHeight && !userResized | ||
| ? autoExpandHeight | ||
| : undefined, |
There was a problem hiding this comment.
inputHeight is now computed but never used, and non-auto-height text areas no longer set an explicit height (the style returns undefined when isAutoHeight is false). This will both fail lint/TS (unused variable) and break st.text_area(height=...) / non-content-height sizing. Consider restoring height: inputHeight for the non-auto-height path while keeping the manual-resize behavior for height="content".
| height: | |
| isAutoHeight && !userResized | |
| ? autoExpandHeight | |
| : undefined, | |
| height: isAutoHeight | |
| ? !userResized | |
| ? autoExpandHeight | |
| : undefined | |
| : inputHeight, |
| // Sync the hook's calculated height to our ref for the ResizeObserver to compare against | ||
| useEffect(() => { | ||
| if (autoExpandHeight) { | ||
| autoHeightRef.current = parseInt(String(autoExpandHeight), 10) || 0 | ||
| } | ||
| }, [autoExpandHeight]) |
There was a problem hiding this comment.
The manual-resize detection compares clientHeight (px) to autoHeightRef.current, but autoExpandHeight can be a non-px value (e.g. theme default "2.5rem" from useTextInputAutoExpand). parseInt("2.5rem", 10) becomes 2, which will cause isAutoExpanding to be false and can incorrectly set userResized=true during normal auto-expansion. Also, autoHeightRef is updated in useEffect, which runs after paint—ResizeObserver callbacks can fire before this ref is updated for the current render. Consider deriving the auto height in px (e.g. reading the observed element’s computed/actual height in a layout effect, using entry.contentRect.height, or detecting manual resize via the presence of an inline textarea.style.height set by the browser) so auto-expansion isn’t misclassified as user resizing.
| // setTimeout ensures the DOM is fully updated with large pastes (Ctrl+V) | ||
| // before we measure scrollHeight to see if the text overflowed the box. | ||
| setTimeout(() => { | ||
| const el = textareaRef.current | ||
|
|
||
| if (userResized && el) { | ||
| // If user manually resized, but then types/pastes past the bottom edge, | ||
| // we clear the manual height and return control to the auto-expand hook. | ||
| if (el.scrollHeight > el.clientHeight + 2) { | ||
| ignoreObserver.current = true // Suspend the ResizeObserver temporarily | ||
| el.style.height = "" // Clear inline manual height | ||
| setUserResized(false) // Give control back to hook | ||
|
|
||
| // Give the React render cycle and auto-expand hook 150ms to measure and apply | ||
| // the giant text height before we re-enable the manual resize detection. | ||
| setTimeout(() => { | ||
| ignoreObserver.current = false | ||
| }, 150) | ||
| } |
There was a problem hiding this comment.
additionalAction schedules nested setTimeout calls but doesn’t track/clear them on unmount. This can lead to state updates after unmount (e.g. setUserResized(false)) and makes timing behavior depend on the 0ms/150ms magic delays. Consider storing timeout IDs in refs and clearing them in an effect cleanup, or switching to requestAnimationFrame/layout effects to wait for DOM updates deterministically.
Describe your changes
When a st.text_area uses height="content", it automatically adjusts its height. However, if a user manually resized the box by dragging the corner and then typed new text, the box would abruptly snap back to the auto-calculated content height, ignoring the manual resize.
This commit fixes the issue by introducing a ResizeObserver to track manual user resizing. When manual resizing is detected, the text value is temporarily removed from the auto-expand hook dependencies. This stops the component from shrinking back on every keystroke.
Additionally, if the user types or pastes text that exceeds the new manual height, the manual height is cleared and control is returned to the auto-expand logic. The height CSS property is also updated to respect the manually dragged size by returning undefined when needed.
Screenshot or video (only for visual changes)
bug_fix.mp4
GitHub Issue Link (if applicable)
#12869
Testing Plan
Video above tested with simple .py file containing:
Contribution License Agreement
By submitting this pull request you agree that all contributions to this project are made under the Apache 2.0 license.