Defensive coding to reduce the risk of Maximum update depth errors (#20940)

This commit is contained in:
Jacob Richman
2026-03-03 22:32:05 -08:00
committed by GitHub
parent 7e06559db2
commit 1017b78157
4 changed files with 122 additions and 25 deletions

View File

@@ -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');

View File

@@ -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}>

View File

@@ -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(
() => ({

View File

@@ -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,