diff --git a/packages/cli/src/ui/components/shared/MaxSizedBox.test.tsx b/packages/cli/src/ui/components/shared/MaxSizedBox.test.tsx
index c5122770c0..d21cebe971 100644
--- a/packages/cli/src/ui/components/shared/MaxSizedBox.test.tsx
+++ b/packages/cli/src/ui/components/shared/MaxSizedBox.test.tsx
@@ -9,9 +9,19 @@ import { OverflowProvider } from '../../contexts/OverflowContext.js';
import { MaxSizedBox } from './MaxSizedBox.js';
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
import { Box, Text } from 'ink';
-import { describe, it, expect } from 'vitest';
+import { act } from 'react';
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
describe('', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+ });
+
it('renders children without truncation when they fit', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
@@ -22,6 +32,9 @@ describe('', () => {
,
);
+ await act(async () => {
+ vi.runAllTimers();
+ });
await waitUntilReady();
expect(lastFrame()).toContain('Hello, World!');
expect(lastFrame()).toMatchSnapshot();
@@ -40,6 +53,9 @@ describe('', () => {
,
);
+ await act(async () => {
+ vi.runAllTimers();
+ });
await waitUntilReady();
expect(lastFrame()).toContain(
'... first 2 lines hidden (Ctrl+O to show) ...',
@@ -60,6 +76,9 @@ describe('', () => {
,
);
+ await act(async () => {
+ vi.runAllTimers();
+ });
await waitUntilReady();
expect(lastFrame()).toContain(
'... last 2 lines hidden (Ctrl+O to show) ...',
@@ -80,6 +99,9 @@ describe('', () => {
,
);
+ await act(async () => {
+ vi.runAllTimers();
+ });
await waitUntilReady();
expect(lastFrame()).toContain(
'... first 2 lines hidden (Ctrl+O to show) ...',
@@ -98,6 +120,9 @@ describe('', () => {
,
);
+ await act(async () => {
+ vi.runAllTimers();
+ });
await waitUntilReady();
expect(lastFrame()).toContain(
'... first 1 line hidden (Ctrl+O to show) ...',
@@ -118,6 +143,9 @@ describe('', () => {
,
);
+ await act(async () => {
+ vi.runAllTimers();
+ });
await waitUntilReady();
expect(lastFrame()).toContain(
'... first 7 lines hidden (Ctrl+O to show) ...',
@@ -137,6 +165,9 @@ describe('', () => {
,
);
+ await act(async () => {
+ vi.runAllTimers();
+ });
await waitUntilReady();
expect(lastFrame()).toContain('This is a');
expect(lastFrame()).toMatchSnapshot();
@@ -154,6 +185,9 @@ describe('', () => {
,
);
+ await act(async () => {
+ vi.runAllTimers();
+ });
await waitUntilReady();
expect(lastFrame()).toContain('Line 1');
expect(lastFrame()).toMatchSnapshot();
@@ -166,6 +200,9 @@ describe('', () => {
,
);
+ await act(async () => {
+ vi.runAllTimers();
+ });
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })?.trim()).equals('');
unmount();
@@ -185,6 +222,9 @@ describe('', () => {
,
);
+ await act(async () => {
+ vi.runAllTimers();
+ });
await waitUntilReady();
expect(lastFrame()).toContain('Line 1 from Fragment');
expect(lastFrame()).toMatchSnapshot();
@@ -206,6 +246,9 @@ describe('', () => {
,
);
+ await act(async () => {
+ vi.runAllTimers();
+ });
await waitUntilReady();
expect(lastFrame()).toContain(
'... first 21 lines hidden (Ctrl+O to show) ...',
@@ -229,6 +272,9 @@ describe('', () => {
,
);
+ await act(async () => {
+ vi.runAllTimers();
+ });
await waitUntilReady();
expect(lastFrame()).toContain(
'... last 21 lines hidden (Ctrl+O to show) ...',
@@ -253,6 +299,9 @@ describe('', () => {
{ width: 80 },
);
+ await act(async () => {
+ vi.runAllTimers();
+ });
await waitUntilReady();
expect(lastFrame()).toContain('... last');
diff --git a/packages/cli/src/ui/components/shared/MaxSizedBox.tsx b/packages/cli/src/ui/components/shared/MaxSizedBox.tsx
index 0c2922ddfb..ee91d34f57 100644
--- a/packages/cli/src/ui/components/shared/MaxSizedBox.tsx
+++ b/packages/cli/src/ui/components/shared/MaxSizedBox.tsx
@@ -96,12 +96,15 @@ export const MaxSizedBox: React.FC = ({
} else {
removeOverflowingId?.(id);
}
-
- return () => {
- removeOverflowingId?.(id);
- };
}, [id, totalHiddenLines, addOverflowingId, removeOverflowingId]);
+ useEffect(
+ () => () => {
+ removeOverflowingId?.(id);
+ },
+ [id, removeOverflowingId],
+ );
+
if (effectiveMaxHeight === undefined) {
return (
diff --git a/packages/cli/src/ui/contexts/OverflowContext.tsx b/packages/cli/src/ui/contexts/OverflowContext.tsx
index cee02090b6..f27108367a 100644
--- a/packages/cli/src/ui/contexts/OverflowContext.tsx
+++ b/packages/cli/src/ui/contexts/OverflowContext.tsx
@@ -11,6 +11,8 @@ import {
useState,
useCallback,
useMemo,
+ useRef,
+ useEffect,
} from 'react';
export interface OverflowState {
@@ -42,31 +44,70 @@ export const OverflowProvider: React.FC<{ children: React.ReactNode }> = ({
}) => {
const [overflowingIds, setOverflowingIds] = useState(new Set());
- const addOverflowingId = useCallback((id: string) => {
- setOverflowingIds((prevIds) => {
- if (prevIds.has(id)) {
- return prevIds;
- }
- const newIds = new Set(prevIds);
- newIds.add(id);
- return newIds;
- });
+ /**
+ * We use a ref to track the current set of overflowing IDs and a timeout to
+ * batch updates to the next tick. This prevents infinite render loops (layout
+ * oscillation) where showing an overflow hint causes a layout shift that
+ * hides the hint, which then restores the layout and shows the hint again.
+ */
+ const idsRef = useRef(new Set());
+ const timeoutRef = useRef(null);
+
+ const syncState = useCallback(() => {
+ if (timeoutRef.current) return;
+
+ // Use a microtask to batch updates and break synchronous recursive loops.
+ // This prevents "Maximum update depth exceeded" errors during layout shifts.
+ timeoutRef.current = setTimeout(() => {
+ timeoutRef.current = null;
+ setOverflowingIds((prevIds) => {
+ // Optimization: only update state if the set has actually changed
+ if (
+ prevIds.size === idsRef.current.size &&
+ [...prevIds].every((id) => idsRef.current.has(id))
+ ) {
+ return prevIds;
+ }
+ return new Set(idsRef.current);
+ });
+ }, 0);
}, []);
- const removeOverflowingId = useCallback((id: string) => {
- setOverflowingIds((prevIds) => {
- if (!prevIds.has(id)) {
- return prevIds;
+ useEffect(
+ () => () => {
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current);
}
- const newIds = new Set(prevIds);
- newIds.delete(id);
- return newIds;
- });
- }, []);
+ },
+ [],
+ );
+
+ const addOverflowingId = useCallback(
+ (id: string) => {
+ if (!idsRef.current.has(id)) {
+ idsRef.current.add(id);
+ syncState();
+ }
+ },
+ [syncState],
+ );
+
+ const removeOverflowingId = useCallback(
+ (id: string) => {
+ if (idsRef.current.has(id)) {
+ idsRef.current.delete(id);
+ syncState();
+ }
+ },
+ [syncState],
+ );
const reset = useCallback(() => {
- setOverflowingIds(new Set());
- }, []);
+ if (idsRef.current.size > 0) {
+ idsRef.current.clear();
+ syncState();
+ }
+ }, [syncState]);
const stateValue = useMemo(
() => ({
diff --git a/packages/cli/test-setup.ts b/packages/cli/test-setup.ts
index aee1c1345e..1b7645c3f4 100644
--- a/packages/cli/test-setup.ts
+++ b/packages/cli/test-setup.ts
@@ -60,6 +60,10 @@ beforeEach(() => {
? stackLines.slice(lastReactFrameIndex + 1).join('\n')
: stackLines.slice(1).join('\n');
+ if (relevantStack.includes('OverflowContext.tsx')) {
+ return;
+ }
+
actWarnings.push({
message: format(...args),
stack: relevantStack,