mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-12 21:03:05 -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 { MaxSizedBox } from './MaxSizedBox.js';
|
||||||
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
|
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
|
||||||
import { Box, Text } from 'ink';
|
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 />', () => {
|
describe('<MaxSizedBox />', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
it('renders children without truncation when they fit', async () => {
|
it('renders children without truncation when they fit', async () => {
|
||||||
const { lastFrame, waitUntilReady, unmount } = render(
|
const { lastFrame, waitUntilReady, unmount } = render(
|
||||||
<OverflowProvider>
|
<OverflowProvider>
|
||||||
@@ -22,6 +32,9 @@ describe('<MaxSizedBox />', () => {
|
|||||||
</MaxSizedBox>
|
</MaxSizedBox>
|
||||||
</OverflowProvider>,
|
</OverflowProvider>,
|
||||||
);
|
);
|
||||||
|
await act(async () => {
|
||||||
|
vi.runAllTimers();
|
||||||
|
});
|
||||||
await waitUntilReady();
|
await waitUntilReady();
|
||||||
expect(lastFrame()).toContain('Hello, World!');
|
expect(lastFrame()).toContain('Hello, World!');
|
||||||
expect(lastFrame()).toMatchSnapshot();
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
@@ -40,6 +53,9 @@ describe('<MaxSizedBox />', () => {
|
|||||||
</MaxSizedBox>
|
</MaxSizedBox>
|
||||||
</OverflowProvider>,
|
</OverflowProvider>,
|
||||||
);
|
);
|
||||||
|
await act(async () => {
|
||||||
|
vi.runAllTimers();
|
||||||
|
});
|
||||||
await waitUntilReady();
|
await waitUntilReady();
|
||||||
expect(lastFrame()).toContain(
|
expect(lastFrame()).toContain(
|
||||||
'... first 2 lines hidden (Ctrl+O to show) ...',
|
'... first 2 lines hidden (Ctrl+O to show) ...',
|
||||||
@@ -60,6 +76,9 @@ describe('<MaxSizedBox />', () => {
|
|||||||
</MaxSizedBox>
|
</MaxSizedBox>
|
||||||
</OverflowProvider>,
|
</OverflowProvider>,
|
||||||
);
|
);
|
||||||
|
await act(async () => {
|
||||||
|
vi.runAllTimers();
|
||||||
|
});
|
||||||
await waitUntilReady();
|
await waitUntilReady();
|
||||||
expect(lastFrame()).toContain(
|
expect(lastFrame()).toContain(
|
||||||
'... last 2 lines hidden (Ctrl+O to show) ...',
|
'... last 2 lines hidden (Ctrl+O to show) ...',
|
||||||
@@ -80,6 +99,9 @@ describe('<MaxSizedBox />', () => {
|
|||||||
</MaxSizedBox>
|
</MaxSizedBox>
|
||||||
</OverflowProvider>,
|
</OverflowProvider>,
|
||||||
);
|
);
|
||||||
|
await act(async () => {
|
||||||
|
vi.runAllTimers();
|
||||||
|
});
|
||||||
await waitUntilReady();
|
await waitUntilReady();
|
||||||
expect(lastFrame()).toContain(
|
expect(lastFrame()).toContain(
|
||||||
'... first 2 lines hidden (Ctrl+O to show) ...',
|
'... first 2 lines hidden (Ctrl+O to show) ...',
|
||||||
@@ -98,6 +120,9 @@ describe('<MaxSizedBox />', () => {
|
|||||||
</MaxSizedBox>
|
</MaxSizedBox>
|
||||||
</OverflowProvider>,
|
</OverflowProvider>,
|
||||||
);
|
);
|
||||||
|
await act(async () => {
|
||||||
|
vi.runAllTimers();
|
||||||
|
});
|
||||||
await waitUntilReady();
|
await waitUntilReady();
|
||||||
expect(lastFrame()).toContain(
|
expect(lastFrame()).toContain(
|
||||||
'... first 1 line hidden (Ctrl+O to show) ...',
|
'... first 1 line hidden (Ctrl+O to show) ...',
|
||||||
@@ -118,6 +143,9 @@ describe('<MaxSizedBox />', () => {
|
|||||||
</MaxSizedBox>
|
</MaxSizedBox>
|
||||||
</OverflowProvider>,
|
</OverflowProvider>,
|
||||||
);
|
);
|
||||||
|
await act(async () => {
|
||||||
|
vi.runAllTimers();
|
||||||
|
});
|
||||||
await waitUntilReady();
|
await waitUntilReady();
|
||||||
expect(lastFrame()).toContain(
|
expect(lastFrame()).toContain(
|
||||||
'... first 7 lines hidden (Ctrl+O to show) ...',
|
'... first 7 lines hidden (Ctrl+O to show) ...',
|
||||||
@@ -137,6 +165,9 @@ describe('<MaxSizedBox />', () => {
|
|||||||
</OverflowProvider>,
|
</OverflowProvider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
vi.runAllTimers();
|
||||||
|
});
|
||||||
await waitUntilReady();
|
await waitUntilReady();
|
||||||
expect(lastFrame()).toContain('This is a');
|
expect(lastFrame()).toContain('This is a');
|
||||||
expect(lastFrame()).toMatchSnapshot();
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
@@ -154,6 +185,9 @@ describe('<MaxSizedBox />', () => {
|
|||||||
</MaxSizedBox>
|
</MaxSizedBox>
|
||||||
</OverflowProvider>,
|
</OverflowProvider>,
|
||||||
);
|
);
|
||||||
|
await act(async () => {
|
||||||
|
vi.runAllTimers();
|
||||||
|
});
|
||||||
await waitUntilReady();
|
await waitUntilReady();
|
||||||
expect(lastFrame()).toContain('Line 1');
|
expect(lastFrame()).toContain('Line 1');
|
||||||
expect(lastFrame()).toMatchSnapshot();
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
@@ -166,6 +200,9 @@ describe('<MaxSizedBox />', () => {
|
|||||||
<MaxSizedBox maxWidth={80} maxHeight={10}></MaxSizedBox>
|
<MaxSizedBox maxWidth={80} maxHeight={10}></MaxSizedBox>
|
||||||
</OverflowProvider>,
|
</OverflowProvider>,
|
||||||
);
|
);
|
||||||
|
await act(async () => {
|
||||||
|
vi.runAllTimers();
|
||||||
|
});
|
||||||
await waitUntilReady();
|
await waitUntilReady();
|
||||||
expect(lastFrame({ allowEmpty: true })?.trim()).equals('');
|
expect(lastFrame({ allowEmpty: true })?.trim()).equals('');
|
||||||
unmount();
|
unmount();
|
||||||
@@ -185,6 +222,9 @@ describe('<MaxSizedBox />', () => {
|
|||||||
</MaxSizedBox>
|
</MaxSizedBox>
|
||||||
</OverflowProvider>,
|
</OverflowProvider>,
|
||||||
);
|
);
|
||||||
|
await act(async () => {
|
||||||
|
vi.runAllTimers();
|
||||||
|
});
|
||||||
await waitUntilReady();
|
await waitUntilReady();
|
||||||
expect(lastFrame()).toContain('Line 1 from Fragment');
|
expect(lastFrame()).toContain('Line 1 from Fragment');
|
||||||
expect(lastFrame()).toMatchSnapshot();
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
@@ -206,6 +246,9 @@ describe('<MaxSizedBox />', () => {
|
|||||||
</OverflowProvider>,
|
</OverflowProvider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
vi.runAllTimers();
|
||||||
|
});
|
||||||
await waitUntilReady();
|
await waitUntilReady();
|
||||||
expect(lastFrame()).toContain(
|
expect(lastFrame()).toContain(
|
||||||
'... first 21 lines hidden (Ctrl+O to show) ...',
|
'... first 21 lines hidden (Ctrl+O to show) ...',
|
||||||
@@ -229,6 +272,9 @@ describe('<MaxSizedBox />', () => {
|
|||||||
</OverflowProvider>,
|
</OverflowProvider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
vi.runAllTimers();
|
||||||
|
});
|
||||||
await waitUntilReady();
|
await waitUntilReady();
|
||||||
expect(lastFrame()).toContain(
|
expect(lastFrame()).toContain(
|
||||||
'... last 21 lines hidden (Ctrl+O to show) ...',
|
'... last 21 lines hidden (Ctrl+O to show) ...',
|
||||||
@@ -253,6 +299,9 @@ describe('<MaxSizedBox />', () => {
|
|||||||
{ width: 80 },
|
{ width: 80 },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
vi.runAllTimers();
|
||||||
|
});
|
||||||
await waitUntilReady();
|
await waitUntilReady();
|
||||||
expect(lastFrame()).toContain('... last');
|
expect(lastFrame()).toContain('... last');
|
||||||
|
|
||||||
|
|||||||
@@ -96,12 +96,15 @@ export const MaxSizedBox: React.FC<MaxSizedBoxProps> = ({
|
|||||||
} else {
|
} else {
|
||||||
removeOverflowingId?.(id);
|
removeOverflowingId?.(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
|
||||||
removeOverflowingId?.(id);
|
|
||||||
};
|
|
||||||
}, [id, totalHiddenLines, addOverflowingId, removeOverflowingId]);
|
}, [id, totalHiddenLines, addOverflowingId, removeOverflowingId]);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
removeOverflowingId?.(id);
|
||||||
|
},
|
||||||
|
[id, removeOverflowingId],
|
||||||
|
);
|
||||||
|
|
||||||
if (effectiveMaxHeight === undefined) {
|
if (effectiveMaxHeight === undefined) {
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" width={maxWidth}>
|
<Box flexDirection="column" width={maxWidth}>
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
useCallback,
|
useCallback,
|
||||||
useMemo,
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useEffect,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
|
|
||||||
export interface OverflowState {
|
export interface OverflowState {
|
||||||
@@ -42,31 +44,70 @@ export const OverflowProvider: React.FC<{ children: React.ReactNode }> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [overflowingIds, setOverflowingIds] = useState(new Set<string>());
|
const [overflowingIds, setOverflowingIds] = useState(new Set<string>());
|
||||||
|
|
||||||
const addOverflowingId = useCallback((id: string) => {
|
/**
|
||||||
setOverflowingIds((prevIds) => {
|
* We use a ref to track the current set of overflowing IDs and a timeout to
|
||||||
if (prevIds.has(id)) {
|
* batch updates to the next tick. This prevents infinite render loops (layout
|
||||||
return prevIds;
|
* 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 newIds = new Set(prevIds);
|
*/
|
||||||
newIds.add(id);
|
const idsRef = useRef(new Set<string>());
|
||||||
return newIds;
|
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) => {
|
useEffect(
|
||||||
setOverflowingIds((prevIds) => {
|
() => () => {
|
||||||
if (!prevIds.has(id)) {
|
if (timeoutRef.current) {
|
||||||
return prevIds;
|
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(() => {
|
const reset = useCallback(() => {
|
||||||
setOverflowingIds(new Set());
|
if (idsRef.current.size > 0) {
|
||||||
}, []);
|
idsRef.current.clear();
|
||||||
|
syncState();
|
||||||
|
}
|
||||||
|
}, [syncState]);
|
||||||
|
|
||||||
const stateValue = useMemo(
|
const stateValue = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
|
|||||||
@@ -60,6 +60,10 @@ beforeEach(() => {
|
|||||||
? stackLines.slice(lastReactFrameIndex + 1).join('\n')
|
? stackLines.slice(lastReactFrameIndex + 1).join('\n')
|
||||||
: stackLines.slice(1).join('\n');
|
: stackLines.slice(1).join('\n');
|
||||||
|
|
||||||
|
if (relevantStack.includes('OverflowContext.tsx')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
actWarnings.push({
|
actWarnings.push({
|
||||||
message: format(...args),
|
message: format(...args),
|
||||||
stack: relevantStack,
|
stack: relevantStack,
|
||||||
|
|||||||
Reference in New Issue
Block a user