Skip to content

Fix #12869: Prevent text area from snapping back after manual resize#14560

Open
joanagcardosoo wants to merge 1 commit intostreamlit:developfrom
joanagcardosoo:fix-textarea-height
Open

Fix #12869: Prevent text area from snapping back after manual resize#14560
joanagcardosoo wants to merge 1 commit intostreamlit:developfrom
joanagcardosoo:fix-textarea-height

Conversation

@joanagcardosoo
Copy link
Copy Markdown

@joanagcardosoo joanagcardosoo commented Mar 28, 2026

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:

import streamlit as st
st.text_area("Testing bug 12869", height="content")

Contribution License Agreement

By submitting this pull request you agree that all contributions to this project are made under the Apache 2.0 license.

…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.
Copilot AI review requested due to automatic review settings March 28, 2026 19:09
@github-actions
Copy link
Copy Markdown
Contributor

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:

  1. Initial triage: A maintainer will apply labels, approve CI to run, and trigger AI-assisted reviews. Your PR may be flagged with status:needs-product-approval if the feature requires product team sign-off.

  2. Code review: A core maintainer will start reviewing your PR once:

    • It is marked as 'ready for review', not 'draft'
    • It has status:product-approved (or doesn't need it)
    • All CI checks pass
    • All AI review comments are addressed

We're receiving many contributions and have limited review bandwidth — please expect some delay. We appreciate your patience! 🙏

@snyk-io
Copy link
Copy Markdown
Contributor

snyk-io bot commented Mar 28, 2026

Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
Open Source Security 0 0 0 0 0 issues
Licenses 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

Comment on lines +128 to +142
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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
})
Suggested change
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

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 useTextInputAutoExpand dependencies 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 height style behavior to avoid overriding user-driven sizing.

Comment on lines 196 to 202
} = 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],
})
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +120 to +121
// Ref to temporarily blindfold the ResizeObserver during large programatic height shifts
const ignoreObserver = useRef(false)
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typo in comment: "programatic" → "programmatic".

Copilot uses AI. Check for mistakes.
Comment on lines +131 to +132
// If we are programatically resetting the height (e.g. huge paste), ignore this event!
if (ignoreObserver.current) return
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typo in comment: "programatically" → "programmatically".

Copilot uses AI. Check for mistakes.
Comment on lines +320 to +323
height:
isAutoHeight && !userResized
? autoExpandHeight
: undefined,
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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".

Suggested change
height:
isAutoHeight && !userResized
? autoExpandHeight
: undefined,
height: isAutoHeight
? !userResized
? autoExpandHeight
: undefined
: inputHeight,

Copilot uses AI. Check for mistakes.
Comment on lines +204 to +209
// 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])
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +230 to +248
// 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)
}
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants