mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-10 14:10:37 -07:00
Defensive coding to reduce the risk of Maximum update depth errors (#20940)
This commit is contained in:
@@ -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('<MaxSizedBox />', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('renders children without truncation when they fit', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
<OverflowProvider>
|
||||
@@ -22,6 +32,9 @@ describe('<MaxSizedBox />', () => {
|
||||
</MaxSizedBox>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
await act(async () => {
|
||||
vi.runAllTimers();
|
||||
});
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('Hello, World!');
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
@@ -40,6 +53,9 @@ describe('<MaxSizedBox />', () => {
|
||||
</MaxSizedBox>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
await act(async () => {
|
||||
vi.runAllTimers();
|
||||
});
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain(
|
||||
'... first 2 lines hidden (Ctrl+O to show) ...',
|
||||
@@ -60,6 +76,9 @@ describe('<MaxSizedBox />', () => {
|
||||
</MaxSizedBox>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
await act(async () => {
|
||||
vi.runAllTimers();
|
||||
});
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain(
|
||||
'... last 2 lines hidden (Ctrl+O to show) ...',
|
||||
@@ -80,6 +99,9 @@ describe('<MaxSizedBox />', () => {
|
||||
</MaxSizedBox>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
await act(async () => {
|
||||
vi.runAllTimers();
|
||||
});
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain(
|
||||
'... first 2 lines hidden (Ctrl+O to show) ...',
|
||||
@@ -98,6 +120,9 @@ describe('<MaxSizedBox />', () => {
|
||||
</MaxSizedBox>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
await act(async () => {
|
||||
vi.runAllTimers();
|
||||
});
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain(
|
||||
'... first 1 line hidden (Ctrl+O to show) ...',
|
||||
@@ -118,6 +143,9 @@ describe('<MaxSizedBox />', () => {
|
||||
</MaxSizedBox>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
await act(async () => {
|
||||
vi.runAllTimers();
|
||||
});
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain(
|
||||
'... first 7 lines hidden (Ctrl+O to show) ...',
|
||||
@@ -137,6 +165,9 @@ describe('<MaxSizedBox />', () => {
|
||||
</OverflowProvider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
vi.runAllTimers();
|
||||
});
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('This is a');
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
@@ -154,6 +185,9 @@ describe('<MaxSizedBox />', () => {
|
||||
</MaxSizedBox>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
await act(async () => {
|
||||
vi.runAllTimers();
|
||||
});
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('Line 1');
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
@@ -166,6 +200,9 @@ describe('<MaxSizedBox />', () => {
|
||||
<MaxSizedBox maxWidth={80} maxHeight={10}></MaxSizedBox>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
await act(async () => {
|
||||
vi.runAllTimers();
|
||||
});
|
||||
await waitUntilReady();
|
||||
expect(lastFrame({ allowEmpty: true })?.trim()).equals('');
|
||||
unmount();
|
||||
@@ -185,6 +222,9 @@ describe('<MaxSizedBox />', () => {
|
||||
</MaxSizedBox>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
await act(async () => {
|
||||
vi.runAllTimers();
|
||||
});
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('Line 1 from Fragment');
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
@@ -206,6 +246,9 @@ describe('<MaxSizedBox />', () => {
|
||||
</OverflowProvider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
vi.runAllTimers();
|
||||
});
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain(
|
||||
'... first 21 lines hidden (Ctrl+O to show) ...',
|
||||
@@ -229,6 +272,9 @@ describe('<MaxSizedBox />', () => {
|
||||
</OverflowProvider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
vi.runAllTimers();
|
||||
});
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain(
|
||||
'... last 21 lines hidden (Ctrl+O to show) ...',
|
||||
@@ -253,6 +299,9 @@ describe('<MaxSizedBox />', () => {
|
||||
{ width: 80 },
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
vi.runAllTimers();
|
||||
});
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('... last');
|
||||
|
||||
|
||||
@@ -96,12 +96,15 @@ export const MaxSizedBox: React.FC<MaxSizedBoxProps> = ({
|
||||
} else {
|
||||
removeOverflowingId?.(id);
|
||||
}
|
||||
|
||||
return () => {
|
||||
removeOverflowingId?.(id);
|
||||
};
|
||||
}, [id, totalHiddenLines, addOverflowingId, removeOverflowingId]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
removeOverflowingId?.(id);
|
||||
},
|
||||
[id, removeOverflowingId],
|
||||
);
|
||||
|
||||
if (effectiveMaxHeight === undefined) {
|
||||
return (
|
||||
<Box flexDirection="column" width={maxWidth}>
|
||||
|
||||
@@ -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<string>());
|
||||
|
||||
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<string>());
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(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(
|
||||
() => ({
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user