diff --git a/.changeset/textarea-autosize-off-screen-measure.md b/.changeset/textarea-autosize-off-screen-measure.md
new file mode 100644
index 000000000..248f35184
--- /dev/null
+++ b/.changeset/textarea-autosize-off-screen-measure.md
@@ -0,0 +1,8 @@
+---
+'@cube-dev/ui-kit': patch
+---
+
+`TextArea` / `CommandTextArea`: `autoSize` no longer disturbs the page while typing, and a single line is one row again.
+
+- **The height is now measured off-screen instead of on the live textarea.** Measuring used to set `height: auto` on the real element, force a layout, then restore it — twice per keystroke. Any ancestor sharing the column re-laid out mid-keystroke, so in a chat layout the transcript's scroll viewport grew by the collapsed rows and its scroll offset moved; the browser's scroll anchoring undid that imperfectly, which reads as the whole conversation bouncing a pixel in the rhythm of typing. A textarea already grown past its `rows` minimum — the everyday state of a chat prompt — lost 40px of scroll offset per keystroke with anchoring out of the way.
+- **Row counting is fixed.** `height: auto` sizes a textarea from its `rows` attribute and the font's own metrics, and that height was being divided by CSS `line-height` to get a row count. Where the line height is tighter than the font's natural line box, one line of text counted as two rows, so an `autoSize` textarea with `rows={1}` rendered a row taller than its content. Row counting now rounds a measured content height that carries no such floor, which also stops a fractional line height (a zoomed page, a percentage preset) from adding a phantom row.
diff --git a/src/components/fields/AGENTS.md b/src/components/fields/AGENTS.md
new file mode 100644
index 000000000..81006e97a
--- /dev/null
+++ b/src/components/fields/AGENTS.md
@@ -0,0 +1,16 @@
+# Fields
+
+Read [`docs/rules/input-components.md`](../../../docs/rules/input-components.md) before touching
+anything here — hook order, the two `useFieldProps` modes, id/label wiring, `wrapWithField`,
+validation props.
+
+## Textarea autosize
+
+`TextArea` and `CommandTextArea` share
+[`TextInput/useAutoSizeTextArea.ts`](TextInput/useAutoSizeTextArea.ts). It measures an off-screen
+mirror on purpose: **never size a live textarea by mutating its own height** (`height: auto` → read
+`scrollHeight` → restore). That re-lays out every ancestor mid-keystroke, and a scroll container
+sharing the column then has its scroll offset moved and imperfectly restored by the browser's scroll
+anchoring — the chat-input jitter of CUB-4042. Note also that `height: auto` sizes a textarea from
+its `rows` attribute and the font's line box, not from CSS `line-height`, so a `scrollHeight` read
+that way carries a floor and cannot report a shrink.
diff --git a/src/components/fields/CommandTextArea/CommandTextArea.browser.test.tsx b/src/components/fields/CommandTextArea/CommandTextArea.browser.test.tsx
new file mode 100644
index 000000000..62cd28298
--- /dev/null
+++ b/src/components/fields/CommandTextArea/CommandTextArea.browser.test.tsx
@@ -0,0 +1,116 @@
+import { renderWithRoot, screen, userEvent, waitFor } from '../../../test';
+
+import { CommandTextArea } from './CommandTextArea';
+
+const input = () => screen.getByTestId('prompt') as HTMLTextAreaElement;
+const scroller = () => screen.getByTestId('scroller');
+
+const heightInRows = (el: HTMLTextAreaElement) => {
+ const style = getComputedStyle(el);
+ const box =
+ style.boxSizing === 'border-box'
+ ? (parseFloat(style.paddingTop) || 0) +
+ (parseFloat(style.paddingBottom) || 0) +
+ (parseFloat(style.borderTopWidth) || 0) +
+ (parseFloat(style.borderBottomWidth) || 0)
+ : 0;
+
+ return (
+ (el.getBoundingClientRect().height - box) / parseFloat(style.lineHeight)
+ );
+};
+
+/** Three rows of content, so the prompt sits above its own `rows` minimum. */
+const GROWN_VALUE = 'one\ntwo\nthree';
+
+/**
+ * A chat layout: a scrolled transcript above, the prompt below, both sharing
+ * one column, so anything that changes the prompt's height changes the
+ * transcript's viewport.
+ *
+ * Two details make the perturbation observable:
+ *
+ * - The prompt starts **grown** (three rows of content against `rows={1}`).
+ * `height: auto` sizes a textarea from its `rows` attribute, so a prompt
+ * sitting at its minimum has nothing to collapse — the state that jitters is
+ * the everyday one where the user has typed a few lines.
+ * - `overflow-anchor: none`. Chrome's scroll anchoring hides a transient scroll
+ * perturbation by undoing it, so with anchoring left on this assertion passes
+ * whether or not the perturbation happens.
+ */
+function ChatHarness() {
+ return (
+
+
+ {Array.from({ length: 40 }, (_, index) => (
+
+ Message {index}
+
+ ))}
+
+
+
+ );
+}
+
+describe('CommandTextArea autoSize', () => {
+ it('gives a single line exactly one row', async () => {
+ renderWithRoot(
+ ,
+ );
+
+ await waitFor(() => expect(heightInRows(input())).toBeCloseTo(1, 1));
+ });
+
+ // CUB-4042: measuring the live textarea (`height: auto` → read `scrollHeight`
+ // → restore) collapsed the prompt mid-keystroke, which grew the transcript's
+ // viewport and moved its scroll offset. The conversation visibly bounced on
+ // every keystroke.
+ it('does not move a sibling scroll container while typing', async () => {
+ renderWithRoot();
+
+ await waitFor(() => expect(heightInRows(input())).toBeCloseTo(3, 1));
+
+ const container = scroller();
+
+ container.scrollTop = container.scrollHeight;
+
+ await waitFor(() => expect(container.scrollTop).toBeGreaterThan(0));
+
+ const before = container.scrollTop;
+
+ // Appending to the last line keeps the row count — and so the prompt's own
+ // height — unchanged, leaving the transient as the only thing that could
+ // move the transcript.
+ await userEvent.click(input());
+ await userEvent.keyboard('{End}x');
+
+ expect(input().value).toBe(`${GROWN_VALUE}x`);
+ expect(heightInRows(input())).toBeCloseTo(3, 1);
+ expect(container.scrollTop).toBe(before);
+ });
+});
diff --git a/src/components/fields/CommandTextArea/CommandTextArea.tsx b/src/components/fields/CommandTextArea/CommandTextArea.tsx
index 1a0edafaf..a9731e8ca 100644
--- a/src/components/fields/CommandTextArea/CommandTextArea.tsx
+++ b/src/components/fields/CommandTextArea/CommandTextArea.tsx
@@ -40,6 +40,7 @@ import {
CubeTextInputBaseProps,
TextInputBase,
} from '../TextInput/TextInputBase';
+import { useAutoSizeTextArea } from '../TextInput/useAutoSizeTextArea';
import { useCaretAnchor } from './useCaretAnchor';
@@ -403,28 +404,13 @@ function CommandTextArea(
isActive: shouldShowPopover,
});
- // ---- height autosize (mirrors TextArea) -------------------------------
- const adjustHeight = useEvent(() => {
- const textarea = inputRef.current;
- if (!textarea || !autoSize) return;
-
- textarea.style.height = 'auto';
- const computedStyle = getComputedStyle(textarea);
- const paddingTop = parseFloat(computedStyle.paddingTop) || 0;
- const paddingBottom = parseFloat(computedStyle.paddingBottom) || 0;
- const borderTop = parseFloat(computedStyle.borderTopWidth) || 0;
- const borderBottom = parseFloat(computedStyle.borderBottomWidth) || 0;
- const lineHeight = parseInt(computedStyle.lineHeight) || 20;
- const contentHeight = textarea.scrollHeight - paddingTop - paddingBottom;
- const computedRows = Math.ceil(contentHeight / lineHeight);
- const targetRows = Math.max(Math.min(computedRows, maxRows), rows);
- const totalHeight =
- targetRows * lineHeight +
- paddingTop +
- paddingBottom +
- borderTop +
- borderBottom;
- textarea.style.height = `${totalHeight}px`;
+ // ---- height autosize (shared with TextArea) ---------------------------
+ const adjustHeight = useAutoSizeTextArea({
+ inputRef,
+ autoSize,
+ rows,
+ maxRows,
+ value: effectiveValue,
});
// ---- useTextField (ARIA wiring for the textarea) ----------------------
@@ -447,20 +433,6 @@ function CommandTextArea(
const useEnvironmentalEffect =
typeof window !== 'undefined' ? useLayoutEffect : useEffect;
- useEnvironmentalEffect(() => {
- if (!autoSize || !inputRef.current) return;
- adjustHeight();
- const resizeObserver = new ResizeObserver(adjustHeight);
- resizeObserver.observe(inputRef.current);
- return () => resizeObserver.disconnect();
- }, [autoSize, inputRef.current]);
-
- useEnvironmentalEffect(() => {
- if (autoSize && inputRef.current) {
- adjustHeight();
- }
- }, [effectiveValue]);
-
// ---- caret restore after commit --------------------------------------
const pendingCaretRef = useRef(null);
useEnvironmentalEffect(() => {
diff --git a/src/components/fields/TextArea/TextArea.browser.test.tsx b/src/components/fields/TextArea/TextArea.browser.test.tsx
new file mode 100644
index 000000000..a90680de9
--- /dev/null
+++ b/src/components/fields/TextArea/TextArea.browser.test.tsx
@@ -0,0 +1,133 @@
+import { renderWithRoot, screen, userEvent, waitFor } from '../../../test';
+
+import { TextArea } from './TextArea';
+
+/**
+ * A line height tighter than the font's natural line box. This is the case the
+ * row arithmetic used to get wrong: a textarea's `height: auto` height comes
+ * from the font's own metrics, so dividing it by a tighter `line-height`
+ * rounded a single line up to two rows.
+ */
+const TIGHT_TYPOGRAPHY = { fontSize: '16px', lineHeight: '16px' } as const;
+
+const input = () => screen.getByTestId('area') as HTMLTextAreaElement;
+
+/** The height one row of this textarea should occupy, borders included. */
+function rowMetrics(el: HTMLTextAreaElement) {
+ const style = getComputedStyle(el);
+ const lineHeight = parseFloat(style.lineHeight);
+ const box =
+ style.boxSizing === 'border-box'
+ ? (parseFloat(style.paddingTop) || 0) +
+ (parseFloat(style.paddingBottom) || 0) +
+ (parseFloat(style.borderTopWidth) || 0) +
+ (parseFloat(style.borderBottomWidth) || 0)
+ : 0;
+
+ return { lineHeight, box };
+}
+
+const heightInRows = (el: HTMLTextAreaElement) => {
+ const { lineHeight, box } = rowMetrics(el);
+
+ return (el.getBoundingClientRect().height - box) / lineHeight;
+};
+
+/**
+ * `autoSize` geometry, in a real browser.
+ *
+ * jsdom reports 0 for every box and has no line boxes, so the row arithmetic
+ * here — the part that decided a single line was two rows tall — is invisible
+ * to the jsdom suite.
+ */
+describe('TextArea autoSize', () => {
+ it('gives a single line exactly one row', async () => {
+ renderWithRoot(
+ ,
+ );
+
+ await waitFor(() => expect(heightInRows(input())).toBeCloseTo(1, 1));
+ });
+
+ it('grows with the content and shrinks back', async () => {
+ renderWithRoot(
+ ,
+ );
+
+ await waitFor(() => expect(heightInRows(input())).toBeCloseTo(1, 1));
+
+ await userEvent.click(input());
+ await userEvent.keyboard('one{Enter}two{Enter}three');
+
+ await waitFor(() => expect(heightInRows(input())).toBeCloseTo(3, 1));
+
+ await userEvent.clear(input());
+
+ await waitFor(() => expect(heightInRows(input())).toBeCloseTo(1, 1));
+ });
+
+ it('stops growing at maxRows', async () => {
+ renderWithRoot(
+ ,
+ );
+
+ await waitFor(() => expect(heightInRows(input())).toBeCloseTo(2, 1));
+ });
+
+ it('counts a trailing newline as a row', async () => {
+ renderWithRoot(
+ ,
+ );
+
+ await userEvent.click(input());
+ await userEvent.keyboard('one{Enter}');
+
+ expect(input().value).toBe('one\n');
+ // The caret sits on an empty second row, so the box has to make room for
+ // it. The mirror is a textarea rather than a div precisely so that it lays
+ // a trailing newline out the same way the live field does.
+ await waitFor(() => expect(heightInRows(input())).toBeCloseTo(2, 1));
+ });
+
+ it('honours rows as a minimum', async () => {
+ renderWithRoot(
+ ,
+ );
+
+ await waitFor(() => expect(heightInRows(input())).toBeCloseTo(3, 1));
+ });
+});
diff --git a/src/components/fields/TextArea/TextArea.tsx b/src/components/fields/TextArea/TextArea.tsx
index ed55e3aeb..dc1e48530 100644
--- a/src/components/fields/TextArea/TextArea.tsx
+++ b/src/components/fields/TextArea/TextArea.tsx
@@ -1,13 +1,6 @@
-import {
- ForwardedRef,
- forwardRef,
- useEffect,
- useLayoutEffect,
- useRef,
-} from 'react';
+import { ForwardedRef, forwardRef, useRef } from 'react';
import { useTextField } from 'react-aria';
-import { useEvent } from '../../../_internal/index';
import { chain, mergeProps, useBufferedValue } from '../../../utils/react';
import {
castNullableStringValue,
@@ -19,6 +12,7 @@ import {
CubeTextInputBaseProps,
TextInputBase,
} from '../TextInput';
+import { useAutoSizeTextArea } from '../TextInput/useAutoSizeTextArea';
export interface CubeTextAreaProps
extends CubeTextInputBaseProps,
@@ -65,44 +59,6 @@ function TextArea(
let localInputRef = useRef(null);
let inputRef = propsInputRef ?? localInputRef;
- const adjustHeight = useEvent(() => {
- const textarea = inputRef.current;
-
- if (!textarea || !autoSize) return;
-
- // Reset height to get the correct scrollHeight
- textarea.style.height = 'auto';
-
- // Get computed styles to account for padding
- const computedStyle = getComputedStyle(textarea);
- const paddingTop = parseFloat(computedStyle.paddingTop) || 0;
- const paddingBottom = parseFloat(computedStyle.paddingBottom) || 0;
- const borderTop = parseFloat(computedStyle.borderTopWidth) || 0;
- const borderBottom = parseFloat(computedStyle.borderBottomWidth) || 0;
-
- // Calculate line height (approximately)
- const lineHeight = parseInt(computedStyle.lineHeight) || 20;
-
- // Calculate content height (excluding padding and border)
- const contentHeight = textarea.scrollHeight - paddingTop - paddingBottom;
-
- // Calculate rows based on content height
- const computedRows = Math.ceil(contentHeight / lineHeight);
-
- // Apply min/max constraints
- const targetRows = Math.max(Math.min(computedRows, maxRows), rows);
-
- // Set the height including padding and border
- const totalHeight =
- targetRows * lineHeight +
- paddingTop +
- paddingBottom +
- borderTop +
- borderBottom;
-
- textarea.style.height = `${totalHeight}px`;
- });
-
// Hold the typed text locally until the controlled value catches up — see `useBufferedValue`.
const buffered = useBufferedValue(value, onChange, {
isBuffered,
@@ -110,6 +66,15 @@ function TextArea(
isReadOnly,
});
+ const adjustHeight = useAutoSizeTextArea({
+ inputRef,
+ autoSize,
+ rows,
+ maxRows,
+ // Keyed on the rendered value, not the prop, so a buffered draft is measured.
+ value: buffered.value,
+ });
+
let { labelProps, inputProps } = useTextField(
{
...otherProps,
@@ -127,30 +92,6 @@ function TextArea(
// Merge user-provided labelProps with aria labelProps
const mergedLabelProps = mergeProps(labelProps, userLabelProps);
- const useEnvironmentalEffect =
- typeof window !== 'undefined' ? useLayoutEffect : useEffect;
-
- // Also call adjustHeight on element resize as that can affect wrapping
- useEnvironmentalEffect(() => {
- if (!autoSize || !inputRef.current) return;
-
- adjustHeight();
-
- const resizeObserver = new ResizeObserver(adjustHeight);
-
- resizeObserver.observe(inputRef?.current);
-
- return () => resizeObserver.disconnect();
- }, [autoSize, inputRef?.current]);
-
- // Adjust height when value changes programmatically (controlled mode with autoSize).
- // Keyed on the rendered value, not the prop, so a buffered draft is measured.
- useEnvironmentalEffect(() => {
- if (autoSize && inputRef.current) {
- adjustHeight();
- }
- }, [buffered.value]);
-
return (
0) return parsed;
+
+ const previousValue = mirror.value;
+
+ mirror.value = 'x';
+
+ const measured = mirror.scrollHeight;
+
+ mirror.value = previousValue;
+
+ return measured > 0 ? measured : FALLBACK_LINE_HEIGHT;
+}
+
+export interface AutoSizeTextAreaOptions {
+ inputRef: RefObject;
+ /** Whether the textarea resizes to fit its content. */
+ autoSize: boolean;
+ /** Minimum number of visible rows. */
+ rows: number;
+ /** Maximum number of visible rows. */
+ maxRows: number;
+ /** The rendered value. A change re-measures, so buffered drafts count too. */
+ value: unknown;
+}
+
+/**
+ * Keeps an `autoSize` textarea's height in step with its content, and returns
+ * the `adjustHeight` callback so a change handler can resize in the same tick
+ * as the keystroke.
+ *
+ * The height is derived from an off-screen mirror rather than from the live
+ * element. Measuring the live element — `height: auto`, read `scrollHeight`,
+ * restore — re-lays out every ancestor mid-keystroke: in a chat-style column
+ * the input box collapses to one row and the scroll viewport grows by the
+ * difference, and the browser's scroll anchoring has to undo that. It does so
+ * imperfectly, which is visible as the whole conversation bouncing by a pixel
+ * on every keystroke (CUB-4042). Measuring off-screen touches no ancestor, so
+ * there is nothing to undo.
+ */
+export function useAutoSizeTextArea({
+ inputRef,
+ autoSize,
+ rows,
+ maxRows,
+ value,
+}: AutoSizeTextAreaOptions) {
+ const mirrorRef = useRef(null);
+
+ const adjustHeight = useEvent(() => {
+ const textarea = inputRef.current;
+
+ if (!textarea || !autoSize) return;
+
+ let mirror = mirrorRef.current;
+
+ if (!mirror) {
+ mirror = createMirror();
+ mirrorRef.current = mirror;
+ document.body.appendChild(mirror);
+ }
+
+ const style = getComputedStyle(textarea);
+ const contentWidth = getContentWidth(textarea, style);
+
+ // Nothing to wrap into: the field is display:none, or has not been laid out
+ // yet. Measuring at zero width would wrap every character onto its own line
+ // and pin the height at `maxRows`; keep the current height instead and wait
+ // for the ResizeObserver to report a real one.
+ if (contentWidth === 0) return;
+
+ for (const prop of MIRROR_TYPOGRAPHY_PROPS) {
+ mirror.style[prop] = style[prop];
+ }
+
+ mirror.style.width = `${contentWidth}px`;
+ mirror.value = textarea.value;
+
+ const lineHeight = getLineHeight(style, mirror);
+ // A textarea lays every line out at `line-height`, so the content height is
+ // a whole number of lines — `round` keeps a fractional line height (a zoomed
+ // page, a percentage preset) from adding a phantom row.
+ const contentRows = Math.max(
+ 1,
+ Math.round(mirror.scrollHeight / lineHeight),
+ );
+ const targetRows = Math.max(Math.min(contentRows, maxRows), rows);
+
+ const paddingTop = parseFloat(style.paddingTop) || 0;
+ const paddingBottom = parseFloat(style.paddingBottom) || 0;
+ const borderTop = parseFloat(style.borderTopWidth) || 0;
+ const borderBottom = parseFloat(style.borderBottomWidth) || 0;
+ // `height` covers padding and border under `border-box` only.
+ const box =
+ style.boxSizing === 'border-box'
+ ? paddingTop + paddingBottom + borderTop + borderBottom
+ : 0;
+
+ const nextHeight = `${targetRows * lineHeight + box}px`;
+
+ // Writing an unchanged height would wake the ResizeObserver below for
+ // nothing.
+ if (textarea.style.height !== nextHeight) {
+ textarea.style.height = nextHeight;
+ }
+ });
+
+ const useEnvironmentalEffect =
+ typeof window !== 'undefined' ? useLayoutEffect : useEffect;
+
+ // Re-measure on element resize as that can affect wrapping.
+ useEnvironmentalEffect(() => {
+ if (!autoSize || !inputRef.current) return;
+
+ adjustHeight();
+
+ const resizeObserver = new ResizeObserver(adjustHeight);
+
+ resizeObserver.observe(inputRef.current);
+
+ return () => resizeObserver.disconnect();
+ }, [autoSize, inputRef.current]);
+
+ // Adjust when the value changes programmatically (controlled mode).
+ useEnvironmentalEffect(() => {
+ if (autoSize && inputRef.current) {
+ adjustHeight();
+ }
+ }, [value]);
+
+ useEffect(
+ () => () => {
+ mirrorRef.current?.remove();
+ mirrorRef.current = null;
+ },
+ [],
+ );
+
+ return adjustHeight;
+}