Skip to content
10 changes: 10 additions & 0 deletions build-tools/jest/setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,14 @@ if (typeof window !== 'undefined') {
require('@testing-library/jest-dom/extend-expect');
const { cleanup } = require('@testing-library/react');
afterEach(cleanup);

// jsdom doesn't implement ResizeObserver. Provide a no-op mock so components
// that use it (e.g. PromptInput) can render without errors in unit tests.
if (!window.ResizeObserver) {
window.ResizeObserver = class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
};
}
}
100 changes: 100 additions & 0 deletions pages/prompt-input/container-resize.page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import React, { useContext, useState } from 'react';

import { FormField, RadioGroup, SpaceBetween } from '~components';
import PromptInput, { PromptInputProps } from '~components/prompt-input';

import AppContext, { AppContextType } from '../app/app-context';
import { SimplePage } from '../app/templates';

const longText =
'This is a long message that should cause the prompt input to grow in height when the container becomes narrower, because the text wraps to more lines. Adding even more text to ensure wrapping.';

const sampleTokens: PromptInputProps.InputToken[] = [
{ type: 'text', value: longText },
{ type: 'reference', id: 'ref-1', label: 'Alice', value: 'alice', menuId: 'mentions' },
{ type: 'text', value: ' and some more text that adds to the wrapping behavior of the component.' },
];

const menus: PromptInputProps.MenuDefinition[] = [
{
id: 'mentions',
trigger: '@',
options: [
{ value: 'alice', label: 'Alice' },
{ value: 'bob', label: 'Bob' },
],
},
];

type PageContext = React.Context<AppContextType<{ inputMode: string; containerWidth: string }>>;

export default function ContainerResizePage() {
const { urlParams, setUrlParams } = useContext(AppContext as PageContext);
const isTokenMode = urlParams.inputMode === 'token';
const containerWidth = Number(urlParams.containerWidth) || 400;

const [tokens, setTokens] = useState<readonly PromptInputProps.InputToken[]>(sampleTokens);
const [value, setValue] = useState(
longText + ' Alice and some more text that adds to the wrapping behavior of the component.'
);

return (
<SimplePage
title="Prompt Input - Container Resize"
subtitle="Tests that the prompt input adjusts height when its container resizes (not just window resize)"
>
<SpaceBetween size="m">
<SpaceBetween size="l" direction="horizontal">
<FormField label="Container width">
<RadioGroup
data-testid="width-radio"
value={String(containerWidth)}
onChange={({ detail }) => setUrlParams({ containerWidth: detail.value })}
items={[
{ value: '400', label: '400px' },
{ value: '600', label: '600px' },
{ value: '800', label: '800px' },
{ value: '1200', label: '1200px' },
]}
/>
</FormField>

<FormField label="Mode">
<RadioGroup
value={isTokenMode ? 'token' : 'plain'}
onChange={({ detail }) => setUrlParams({ inputMode: detail.value })}
items={[
{ value: 'token', label: 'Token mode' },
{ value: 'plain', label: 'Plain text' },
]}
/>
</FormField>
</SpaceBetween>

<div
data-testid="resizable-container"
style={{
width: containerWidth,
}}
>
<PromptInput
value={isTokenMode ? undefined : value}
onChange={event => {
setValue(event.detail.value);
if (event.detail.tokens) {
setTokens(event.detail.tokens);
}
}}
tokens={isTokenMode ? tokens : undefined}
menus={isTokenMode ? menus : undefined}
maxRows={10}
placeholder="Type here..."
data-testid="prompt-input"
/>
</div>
</SpaceBetween>
</SimplePage>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import { useRef } from 'react';

import { renderHook } from '../../../../__tests__/render-hook';
import { useWidthChange } from '../index';

describe('useWidthChange', () => {
test('does not throw when ref is null', () => {
const onWidthChange = jest.fn();

expect(() => {
renderHook(() => {
const ref = useRef<HTMLElement>(null);
useWidthChange(ref, onWidthChange);
});
}).not.toThrow();

expect(onWidthChange).not.toHaveBeenCalled();
});
});
34 changes: 34 additions & 0 deletions src/internal/hooks/use-width-change/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import { useEffect, useRef } from 'react';

/**
* Observes an element for inline-size (width) changes and calls `onWidthChange`
* when the width changes. Height-only changes are ignored to prevent infinite
* loops when the callback adjusts the element's height.
*
* Unlike useResizeObserver from the component-toolkit package, it does not cause
* re-renders when the width changes.
*
* @param elementRef - A ref object pointing to the element to observe.
* @param onWidthChange - Callback fired when the element's width changes.
*/
export function useWidthChange(elementRef: React.RefObject<HTMLElement>, onWidthChange: () => void): void {
Copy link
Copy Markdown
Member

@amanabiy amanabiy Jun 5, 2026

Choose a reason for hiding this comment

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

Is it possible to use useResizeObserver instead of useWidthChange?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Extended the comment text to explain the difference:

Unlike useResizeObserver from the component-toolkit package, it does not cause re-renders when the width changes.

const lastWidthRef = useRef(-1);

useEffect(() => {
const node = elementRef.current;
if (!node) {
return;
}
const observer = new ResizeObserver(() => {
const newWidth = node.getBoundingClientRect().width;
if (newWidth !== lastWidthRef.current) {
lastWidthRef.current = newWidth;
onWidthChange();
}
});
observer.observe(node);
return () => observer.disconnect();
}, [elementRef, onWidthChange]);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import { BasePageObject } from '@cloudscape-design/browser-test-tools/page-objects';
import useBrowser from '@cloudscape-design/browser-test-tools/use-browser';

import createWrapper from '../../../lib/components/test-utils/selectors/index.js';
import { isReact18 } from './utils';

const wrapper = createWrapper();
const promptInputSelector = wrapper.findPromptInput('[data-testid="prompt-input"]').toSelector();

class ContainerResizePage extends BasePageObject {
async getPromptInputHeight() {
const { height } = await this.getBoundingBox(promptInputSelector);
return height;
}

async selectWidth(value: string) {
await this.click(wrapper.findRadioGroup('[data-testid="width-radio"]').findInputByValue(value).toSelector());
}
}

describe.each(isReact18 ? ['token', 'plain'] : ['plain'])('Prompt Input - Container Resize (mode=%s)', mode => {
test(
'adjusts height when container width changes',
useBrowser(async browser => {
const page = new ContainerResizePage(browser);
await browser.url(`#/prompt-input/container-resize?inputMode=${mode}&containerWidth=400`);
await page.waitForVisible(promptInputSelector);

// Start at 400px — text should be wrapping, so height is tall
const heightAt400 = await page.getPromptInputHeight();

// Widen to 1200px — text should unwrap, height should decrease
await page.selectWidth('1200');
await page.pause(500);
const heightAt1200 = await page.getPromptInputHeight();

expect(heightAt1200).toBeLessThan(heightAt400);

// Narrow back to 400px — height should increase again
await page.selectWidth('400');
await page.pause(500);
const heightAt400Again = await page.getPromptInputHeight();

expect(heightAt400Again).toBeGreaterThan(heightAt1200);
})
);
});
4 changes: 2 additions & 2 deletions src/prompt-input/__integ__/prompt-input-token-mode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@
import { BasePageObject } from '@cloudscape-design/browser-test-tools/page-objects';
import useBrowser from '@cloudscape-design/browser-test-tools/use-browser';

import createWrapper from '../../../lib/components/test-utils/selectors/index.js';
import createWrapper from '../../../lib/components/test-utils/selectors/index';
import { isReact18 } from './utils';

const promptInputWrapper = createWrapper().findPromptInput('[data-testid="prompt-input"]');
const contentEditableSelector = promptInputWrapper.findContentEditableElement()!.toSelector();
const textareaSelector = promptInputWrapper.findNativeTextarea()!.toSelector();
const menuSelector = promptInputWrapper.findOpenMenu()!.toSelector();
const isReact18 = process.env.REACT_VERSION === '18';

class PromptInputTokenModePage extends BasePageObject {
async focusInput() {
Expand Down
4 changes: 4 additions & 0 deletions src/prompt-input/__integ__/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

export const isReact18 = process.env.REACT_VERSION === '18';
38 changes: 29 additions & 9 deletions src/prompt-input/__tests__/prompt-input-token-mode.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5181,33 +5181,53 @@ describe('token mode misc', () => {
expect(getCaretOffset()).toBe(3);
});

test('unmounting cleans up resize listener and clears portal containers', () => {
const resizeSpy = jest.spyOn(window, 'removeEventListener');
test('unmounting cleans up ResizeObserver and clears portal containers', () => {
const originalResizeObserver = window.ResizeObserver;
const disconnectSpy = jest.fn();
const observeSpy = jest.fn();
const MockResizeObserver = jest.fn(() => ({
observe: observeSpy,
unobserve: jest.fn(),
disconnect: disconnectSpy,
}));
window.ResizeObserver = MockResizeObserver;

const { wrapper, container } = renderTokenMode({
props: { tokens: [{ type: 'reference', id: 'r1', label: 'Alice', value: 'user-1', menuId: 'mentions' }] },
});
// Verify reference is rendered
expect(getValue(wrapper)).toContain('Alice');
// Unmount — this should clean up resize listener and clear containers
expect(observeSpy).toHaveBeenCalled();
// Unmount — this should disconnect the ResizeObserver
const { unmount } = render(<div />, { container });
unmount();
// Verify resize listener was cleaned up
const resizeCalls = resizeSpy.mock.calls.filter(([event]) => event === 'resize');
expect(resizeCalls.length).toBeGreaterThan(0);
resizeSpy.mockRestore();
expect(disconnectSpy).toHaveBeenCalled();
window.ResizeObserver = originalResizeObserver;
});

test('window resize triggers height adjustment without error', () => {
test('ResizeObserver triggers height adjustment without error', () => {
const originalResizeObserver = window.ResizeObserver;
let resizeCallback: () => void = () => {};
const MockResizeObserver = jest.fn(cb => ({
observe: () => {
resizeCallback = cb;
},
unobserve: jest.fn(),
disconnect: jest.fn(),
}));
window.ResizeObserver = MockResizeObserver;

const ref = React.createRef<PromptInputProps.Ref>();
const { wrapper } = renderTokenMode({ props: { tokens: [{ type: 'text', value: 'hello' }] }, ref });
act(() => {
ref.current!.focus();
});
act(() => {
window.dispatchEvent(new Event('resize'));
resizeCallback();
});
// The component should still render correctly and preserve content after resize
expect(getValue(wrapper)).toBe('hello');
window.ResizeObserver = originalResizeObserver;
});

test('Enter key fires onAction through the keyboard handler config', () => {
Expand Down
17 changes: 15 additions & 2 deletions src/prompt-input/internal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@
import React, { Ref, useEffect, useImperativeHandle, useRef } from 'react';
import clsx from 'clsx';

import { useDensityMode, useStableCallback, warnOnce } from '@cloudscape-design/component-toolkit/internal';
import {
useDensityMode,
useMergeRefs,
useStableCallback,
warnOnce,
} from '@cloudscape-design/component-toolkit/internal';

import InternalButton from '../button/internal';
import { useInternalI18n } from '../i18n/context';
Expand All @@ -14,6 +19,7 @@ import { fireCancelableEvent, fireKeyboardEvent, fireNonCancelableEvent } from '
import * as designTokens from '../internal/generated/styles/tokens';
import { InternalBaseComponentProps } from '../internal/hooks/use-base-component';
import { useVisualRefresh } from '../internal/hooks/use-visual-mode';
import { useWidthChange } from '../internal/hooks/use-width-change';
import { isDevelopment } from '../internal/is-development';
import { SomeRequired } from '../internal/types';
import InternalLiveRegion from '../live-region/internal';
Expand Down Expand Up @@ -190,6 +196,13 @@ const InternalPromptInput = React.forwardRef(
}
}, [isTokenMode, value, adjustInputHeight, isCompactMode, placeholder]);

// Observe width changes on the component's root element to handle container resizes.
// We need a separate RefObject because __internalRootRef may be a callback ref,
// and useWidthChange needs a RefObject with .current to read the DOM node.
const rootElementRef = useRef<HTMLDivElement>(null);
const mergedRootRef = useMergeRefs(__internalRootRef, rootElementRef);
useWidthChange(rootElementRef, adjustInputHeight);

const plainTextValue = isTokenMode
? tokensToText
? tokensToText(tokens ?? [])
Expand Down Expand Up @@ -390,7 +403,7 @@ const InternalPromptInput = React.forwardRef(
[styles['textarea-warning']]: warning && !invalid,
[styles.disabled]: disabled,
})}
ref={__internalRootRef}
ref={mergedRootRef}
role="region"
style={getPromptInputStyles(style)}
>
Expand Down
9 changes: 0 additions & 9 deletions src/prompt-input/tokens/use-token-mode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -998,15 +998,6 @@ export function useTokenMode(config: UseTokenModeConfig): UseTokenModeResult {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

useEffect(() => {
const ownerWindow = (editableElementRef.current?.ownerDocument ?? document).defaultView ?? window;
const handleResize = () => adjustInputHeight();
ownerWindow.addEventListener('resize', handleResize);
return () => {
ownerWindow.removeEventListener('resize', handleResize);
};
}, [adjustInputHeight, editableElementRef]);

const menuLoadMoreResult = useMenuLoadMore({
menu: activeMenu ?? { id: '', trigger: '', options: [] },
statusType: activeMenu?.statusType ?? 'finished',
Expand Down
Loading