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,