From e6e075996a79ec3cd64ec9783e7aefb6a90a49d6 Mon Sep 17 00:00:00 2001 From: Zaali Kavelashvili Date: Sun, 13 Sep 2026 06:05:26 +0000 Subject: [PATCH] fix(cli): break Ink useBoxMetrics commit-phase setState loop (React #185) (#11565) * fix(cli): break Ink useBoxMetrics commit-phase setState loop (React #185) * fix(cli): scope the ink useBoxMetrics loop guard per instance The guard added to the vendored Ink patch kept its 32-entry budget in module scope, so every useBoxMetrics instance shared one budget, and the budget counted invocations rather than oscillation cycles. With 40 boxes mounting in a single commit, instances past the 32nd invocation never measured: hasMeasured stayed false for good, because no later commit re-fires their layout listener. Hold the budget in a ref so each instance refills on its own. Cancel the pending budget-refill timer on unmount. Vitest fakes setImmediate by default and vi.useRealTimers() discards a pending faked immediate, so the stored handle stayed non-null after the timer was gone and the counter never refilled. Drop the functional same-value bail for setHasMeasured. The useState setter already bails for an identical primitive, and the functional form cannot be eagerly bailed - React has to schedule the render to evaluate the updater. Reverted to upstream's setHasMeasured(Boolean(ref.current)). Regenerate patches/ink+7.0.3.patch with patch-package instead of hand-appending the hunk, so it lands in path order with an index line and function-context headers. The regeneration also reformats the pre-existing hunks (index hashes, hunk-context labels, offsets); that churn is cosmetic, and the only content change outside the new hunk is one "\ No newline at end of file" marker. Adds packages/cli/src/ui/use-box-metrics-loop-guard.test.tsx, which renders the real hook and pins three behaviours: an oscillating box settles without React #185, a later resize still re-measures once an oscillation tripped the guard, and 40 instances all measure within one commit. * fix(cli): refill the ink useBoxMetrics budget from elapsed time The measurement budget added to the vendored ink hook refilled itself from a setImmediate callback. A test that installs fake timers only runs that callback when it advances the clock, and the InputPrompt suite flushes renders with microtask yields inside act(), so the refill stayed pending and React's act queue never drained with it. Ten tests in that file each burned their full 15s timeout; on CI, where the suite runs with --retry=2, that single file outlived the 60 minute job limit and the lane was cancelled without printing a summary. Refilling from elapsed wall-clock time keeps the same per-instance budget and the same protection against the commit -> layout listener -> setState loop, and leaves no handle for a test to drain, so the unmount cleanup is no longer needed either. --------- Co-authored-by: yiliang114 Co-authored-by: yiliang114 <1204183885@qq.com> --- .../ui/use-box-metrics-loop-guard.test.tsx | 173 ++++++++++++++++++ patches/ink+7.0.3.patch | 86 ++++++--- 2 files changed, 235 insertions(+), 24 deletions(-) create mode 100644 packages/cli/src/ui/use-box-metrics-loop-guard.test.tsx diff --git a/packages/cli/src/ui/use-box-metrics-loop-guard.test.tsx b/packages/cli/src/ui/use-box-metrics-loop-guard.test.tsx new file mode 100644 index 00000000000..b27c2e616e7 --- /dev/null +++ b/packages/cli/src/ui/use-box-metrics-loop-guard.test.tsx @@ -0,0 +1,173 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { act, useRef, type ReactNode } from 'react'; +import { + Box, + render, + Text, + type DOMElement, + type Instance, + useBoxMetrics, +} from 'ink'; +import stripAnsi from 'strip-ansi'; +import { afterEach, describe, expect, it } from 'vitest'; + +/** + * Regression coverage for the `useBoxMetrics` loop guard carried in the + * vendored Ink patch (`patches/ink+7.0.3.patch`). A box whose measured layout + * feeds back into its own size oscillates through the commit-phase layout + * listener until React throws #185 and the CLI exits silently (#11500). + */ + +const mounted = new Set(); + +afterEach(async () => { + for (const app of mounted) { + await act(async () => { + app.unmount(); + }); + await app.waitUntilRenderFlush(); + } + mounted.clear(); +}); + +function createTestStdout( + initialColumns = 80, + rows = 24, +): { + stdout: NodeJS.WriteStream; + setColumns: (columns: number) => void; + lastFrame: () => string; +} { + let columns = initialColumns; + let lastFrame = ''; + const stdout = Object.create(process.stdout, { + columns: { get: () => columns }, + rows: { value: rows }, + isTTY: { value: true }, + write: { + value( + chunk: string | Uint8Array, + encodingOrCallback?: BufferEncoding | (() => void), + callback?: () => void, + ) { + const text = stripAnsi(String(chunk)); + if (text.trim() !== '') { + lastFrame = text; + } + const done = + typeof encodingOrCallback === 'function' + ? encodingOrCallback + : callback; + done?.(); + return true; + }, + }, + }) as NodeJS.WriteStream; + + return { + stdout, + setColumns: (next: number) => { + columns = next; + }, + lastFrame: () => lastFrame, + }; +} + +async function mount( + node: ReactNode, + stdout: NodeJS.WriteStream, +): Promise { + let app!: Instance; + await act(async () => { + app = render(node, { + stdout, + interactive: true, + maxFps: 1_000, + patchConsole: false, + }); + }); + mounted.add(app); + await app.waitUntilRenderFlush(); + return app; +} + +function MeasuredBox({ + id, + width = 20, +}: { + id: string; + width?: number | string; +}) { + const ref = useRef(null); + const { width: measuredWidth, hasMeasured } = useBoxMetrics(ref); + return ( + + {hasMeasured ? `${id}:${measuredWidth}` : `${id}:pending`} + + ); +} + +// Flips its own width on every measurement, so each commit produces a layout +// that disagrees with the previous one and the measure -> setState -> commit +// cycle never settles without the guard. +function OscillatingBox() { + const ref = useRef(null); + const { width, hasMeasured } = useBoxMetrics(ref); + return ( + = 11 ? 10 : 11}> + x + + ); +} + +describe('ink useBoxMetrics loop guard', () => { + it('settles an oscillating box instead of throwing React #185', async () => { + const { stdout, lastFrame } = createTestStdout(); + await mount(, stdout); + + // The oscillator re-renders until its budget trips. Without the guard this + // mount throws "Maximum update depth exceeded" out of the commit phase. + expect(lastFrame()).toContain('x'); + }); + + it('still measures on a later resize once an oscillation tripped the guard', async () => { + const { stdout, setColumns, lastFrame } = createTestStdout(80); + const app = await mount( + + + + , + stdout, + ); + expect(lastFrame()).toContain('probe:80'); + + setColumns(60); + await act(async () => { + stdout.emit('resize'); + }); + await app.waitUntilRenderFlush(); + + expect(lastFrame()).toContain('probe:60'); + }); + + it('gives every instance its own measurement budget', async () => { + const { stdout, lastFrame } = createTestStdout(120, 60); + await mount( + + {Array.from({ length: 40 }, (_, index) => ( + + ))} + , + stdout, + ); + + const frame = lastFrame(); + expect(frame).not.toContain(':pending'); + expect(frame).toContain('box-39:20'); + }); +}); diff --git a/patches/ink+7.0.3.patch b/patches/ink+7.0.3.patch index 3d69b922c0e..bacce056a35 100644 --- a/patches/ink+7.0.3.patch +++ b/patches/ink+7.0.3.patch @@ -18,7 +18,7 @@ index 1d7bf6b..29401a6 100644 -export default function Text({ color, backgroundColor, dimColor, bold, italic, underline, strikethrough, inverse, wrap, children, 'aria-label': ariaLabel, 'aria-hidden': ariaHidden, }: Props): React.JSX.Element | null; +export default function Text({ color, backgroundColor, dimColor, bold, italic, underline, strikethrough, inverse, wrap, selectable, selectionFlow, selectionBreakAfter, selectionJoiner, children, 'aria-label': ariaLabel, 'aria-hidden': ariaHidden, }: Props): React.JSX.Element | null; diff --git a/node_modules/ink/build/components/Text.js b/node_modules/ink/build/components/Text.js -index 2e4b9ff..1ec2ae6 100644 +index 2e4b9ff..77ed0f6 100644 --- a/node_modules/ink/build/components/Text.js +++ b/node_modules/ink/build/components/Text.js @@ -6,7 +6,7 @@ import { backgroundContext } from './BackgroundContext.js'; @@ -38,6 +38,7 @@ index 2e4b9ff..1ec2ae6 100644 + return (React.createElement("ink-text", { style: { flexGrow: 0, flexShrink: 1, flexDirection: 'row', textWrap: wrap }, internal_transform: transform, selectable: selectable, selectionFlow: selectionFlow, selectionBreakAfter: selectionBreakAfter, selectionJoiner: selectionJoiner }, childrenOrAriaLabel)); } //# sourceMappingURL=Text.js.map +\ No newline at end of file diff --git a/node_modules/ink/build/cursor-helpers.d.ts b/node_modules/ink/build/cursor-helpers.d.ts index c6e9466..6dd4c0f 100644 --- a/node_modules/ink/build/cursor-helpers.d.ts @@ -202,6 +203,43 @@ index 0000000..745a4ba +}; +export const getFrameController = (stdout) => instances.get(stdout)?.frameController; +//# sourceMappingURL=frame-controller.js.map +diff --git a/node_modules/ink/build/hooks/use-box-metrics.js b/node_modules/ink/build/hooks/use-box-metrics.js +index c4386e4..d1921ca 100644 +--- a/node_modules/ink/build/hooks/use-box-metrics.js ++++ b/node_modules/ink/build/hooks/use-box-metrics.js +@@ -1,4 +1,4 @@ +-import { useState, useEffect, useCallback, useMemo } from 'react'; ++import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; + import { addLayoutListener } from '../dom.js'; + import useStdout from './use-stdout.js'; + const emptyMetrics = { +@@ -50,8 +50,26 @@ const useBoxMetrics = ( + ref) => { + const [metrics, setMetrics] = useState(emptyMetrics); + const [hasMeasured, setHasMeasured] = useState(false); ++ // Per-instance budget breaking the commit -> layout listener -> setState ++ // feedback loop. A box whose measured size feeds back into its own layout ++ // can oscillate, re-measuring and re-rendering until React throws error ++ // #185 and the process dies. The budget refills as wall-clock time passes, ++ // so mount, resize, and content-driven measuring are unaffected. A timer ++ // based refill would leave a pending handle that fake-timer tests never ++ // drain, which stalls React's act queue. ++ const guardRef = useRef({ count: 0, windowStart: 0 }); + const { stdout } = useStdout(); + const updateMetrics = useCallback(() => { ++ const guard = guardRef.current; ++ const now = performance.now(); ++ if (now - guard.windowStart >= 16) { ++ guard.count = 0; ++ guard.windowStart = now; ++ } ++ if (guard.count >= 32) { ++ return; ++ } ++ guard.count += 1; + const layout = ref.current?.yogaNode?.getComputedLayout() ?? emptyMetrics; + setMetrics(previousMetrics => { + const hasChanged = previousMetrics.width !== layout.width || diff --git a/node_modules/ink/build/index.d.ts b/node_modules/ink/build/index.d.ts index f3e7349..cce56fe 100644 --- a/node_modules/ink/build/index.d.ts @@ -224,7 +262,7 @@ index cc849f0..25d6b33 100644 //# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/node_modules/ink/build/ink.js b/node_modules/ink/build/ink.js -index ef659f3..767d861 100644 +index ef659f3..1beca9b 100644 --- a/node_modules/ink/build/ink.js +++ b/node_modules/ink/build/ink.js @@ -17,6 +17,7 @@ import { hideCursorEscape, showCursorEscape } from './cursor-helpers.js'; @@ -290,7 +328,7 @@ index ef659f3..767d861 100644 const hasStaticOutput = staticOutput !== ''; const isTty = this.options.stdout.isTTY; // Detect fullscreen: output fills or exceeds terminal height. -@@ -710,7 +729,7 @@ export default class Ink { +@@ -707,7 +726,7 @@ export default class Ink { if (sync) { this.options.stdout.write(bsu); } @@ -300,10 +338,10 @@ index ef659f3..767d861 100644 this.lastOutputToRender = outputToRender; this.lastOutputHeight = outputHeight; diff --git a/node_modules/ink/build/log-update.js b/node_modules/ink/build/log-update.js -index cdb5fc4..3247e02 100644 +index cdb5fc4..cd10dc2 100644 --- a/node_modules/ink/build/log-update.js +++ b/node_modules/ink/build/log-update.js -@@ -4,6 +4,10 @@ +@@ -4,6 +4,10 @@ import { cursorPositionChanged, buildCursorSuffix, buildCursorOnlySequence, buil // Count visible lines in a string, ignoring the trailing empty element // that `split('\n')` produces when the string ends with '\n'. const visibleLineCount = (lines, str) => str.endsWith('\n') ? lines.length - 1 : lines.length; @@ -314,7 +352,7 @@ index cdb5fc4..3247e02 100644 const createStandard = (stream, { showCursor = false } = {}) => { let previousLineCount = 0; let previousOutput = ''; -@@ -12,7 +16,26 @@ +@@ -12,7 +16,26 @@ const createStandard = (stream, { showCursor = false } = {}) => { let cursorDirty = false; let previousCursorPosition; let cursorWasShown = false; @@ -342,7 +380,7 @@ index cdb5fc4..3247e02 100644 const hasChanges = (str, activeCursor) => { const cursorChanged = cursorPositionChanged(activeCursor, previousCursorPosition); return str !== previousOutput || cursorChanged; -@@ -24,7 +47,7 @@ +@@ -24,7 +47,7 @@ const createStandard = (stream, { showCursor = false } = {}) => { } // Only use cursor if setCursorPosition was called since last render. // This ensures stale positions don't persist after component unmount. @@ -351,7 +389,7 @@ index cdb5fc4..3247e02 100644 cursorDirty = false; const cursorChanged = cursorPositionChanged(activeCursor, previousCursorPosition); if (!hasChanges(str, activeCursor)) { -@@ -32,7 +55,8 @@ +@@ -32,7 +55,8 @@ const createStandard = (stream, { showCursor = false } = {}) => { } const lines = str.split('\n'); const visibleCount = visibleLineCount(lines, str); @@ -361,7 +399,7 @@ index cdb5fc4..3247e02 100644 if (str === previousOutput && cursorChanged) { stream.write(buildCursorOnlySequence({ cursorWasShown, -@@ -40,6 +64,7 @@ +@@ -40,6 +64,7 @@ const createStandard = (stream, { showCursor = false } = {}) => { previousCursorPosition, visibleLineCount: visibleCount, cursorPosition: activeCursor, @@ -369,7 +407,7 @@ index cdb5fc4..3247e02 100644 })); } else { -@@ -50,6 +75,7 @@ +@@ -50,6 +75,7 @@ const createStandard = (stream, { showCursor = false } = {}) => { str + cursorSuffix); previousLineCount = lines.length; @@ -377,7 +415,7 @@ index cdb5fc4..3247e02 100644 } previousCursorPosition = activeCursor ? { ...activeCursor } : undefined; cursorWasShown = activeCursor !== undefined; -@@ -62,12 +88,16 @@ +@@ -62,12 +88,16 @@ const createStandard = (stream, { showCursor = false } = {}) => { previousLineCount = 0; previousCursorPosition = undefined; cursorWasShown = false; @@ -394,7 +432,7 @@ index cdb5fc4..3247e02 100644 if (!showCursor) { cliCursor.show(stream); hasHiddenCursor = false; -@@ -78,18 +108,21 @@ +@@ -78,18 +108,21 @@ const createStandard = (stream, { showCursor = false } = {}) => { previousLineCount = 0; previousCursorPosition = undefined; cursorWasShown = false; @@ -418,7 +456,7 @@ index cdb5fc4..3247e02 100644 } previousCursorPosition = activeCursor ? { ...activeCursor } : undefined; cursorWasShown = activeCursor !== undefined; -@@ -97,9 +130,10 @@ +@@ -97,9 +130,10 @@ const createStandard = (stream, { showCursor = false } = {}) => { render.setCursorPosition = (position) => { cursorPosition = position; cursorDirty = true; @@ -430,7 +468,7 @@ index cdb5fc4..3247e02 100644 return render; }; const createIncremental = (stream, { showCursor = false } = {}) => { -@@ -110,7 +144,26 @@ +@@ -110,7 +144,26 @@ const createIncremental = (stream, { showCursor = false } = {}) => { let cursorDirty = false; let previousCursorPosition; let cursorWasShown = false; @@ -458,7 +496,7 @@ index cdb5fc4..3247e02 100644 const hasChanges = (str, activeCursor) => { const cursorChanged = cursorPositionChanged(activeCursor, previousCursorPosition); return str !== previousOutput || cursorChanged; -@@ -122,7 +175,7 @@ +@@ -122,7 +175,7 @@ const createIncremental = (stream, { showCursor = false } = {}) => { } // Only use cursor if setCursorPosition was called since last render. // This ensures stale positions don't persist after component unmount. @@ -467,7 +505,7 @@ index cdb5fc4..3247e02 100644 cursorDirty = false; const cursorChanged = cursorPositionChanged(activeCursor, previousCursorPosition); if (!hasChanges(str, activeCursor)) { -@@ -138,6 +191,7 @@ +@@ -138,6 +191,7 @@ const createIncremental = (stream, { showCursor = false } = {}) => { previousCursorPosition, visibleLineCount: visibleCount, cursorPosition: activeCursor, @@ -475,7 +513,7 @@ index cdb5fc4..3247e02 100644 })); previousCursorPosition = activeCursor ? { ...activeCursor } : undefined; cursorWasShown = activeCursor !== undefined; -@@ -145,7 +199,7 @@ +@@ -145,7 +199,7 @@ const createIncremental = (stream, { showCursor = false } = {}) => { } const returnPrefix = buildReturnToBottomPrefix(cursorWasShown, previousLines.length, previousCursorPosition); if (str === '\n' || previousOutput.length === 0) { @@ -484,7 +522,7 @@ index cdb5fc4..3247e02 100644 stream.write(returnPrefix + ansiEscapes.eraseLines(previousLines.length) + str + -@@ -154,6 +208,7 @@ +@@ -154,6 +208,7 @@ const createIncremental = (stream, { showCursor = false } = {}) => { previousCursorPosition = activeCursor ? { ...activeCursor } : undefined; previousOutput = str; previousLines = nextLines; @@ -492,7 +530,7 @@ index cdb5fc4..3247e02 100644 return true; } const hasTrailingNewline = str.endsWith('\n'); -@@ -187,13 +242,14 @@ +@@ -187,13 +242,14 @@ const createIncremental = (stream, { showCursor = false } = {}) => { // has no trailing newline (fullscreen mode). (isLastLine && !hasTrailingNewline ? '' : '\n')); } @@ -508,7 +546,7 @@ index cdb5fc4..3247e02 100644 return true; }; render.clear = () => { -@@ -203,12 +259,16 @@ +@@ -203,12 +259,16 @@ const createIncremental = (stream, { showCursor = false } = {}) => { previousLines = []; previousCursorPosition = undefined; cursorWasShown = false; @@ -525,7 +563,7 @@ index cdb5fc4..3247e02 100644 if (!showCursor) { cliCursor.show(stream); hasHiddenCursor = false; -@@ -219,18 +279,21 @@ +@@ -219,18 +279,21 @@ const createIncremental = (stream, { showCursor = false } = {}) => { previousLines = []; previousCursorPosition = undefined; cursorWasShown = false; @@ -549,7 +587,7 @@ index cdb5fc4..3247e02 100644 } previousCursorPosition = activeCursor ? { ...activeCursor } : undefined; cursorWasShown = activeCursor !== undefined; -@@ -238,9 +301,10 @@ +@@ -238,9 +301,10 @@ const createIncremental = (stream, { showCursor = false } = {}) => { render.setCursorPosition = (position) => { cursorPosition = position; cursorDirty = true; @@ -596,7 +634,7 @@ index 0f4523f..a3ef634 100644 } export {}; diff --git a/node_modules/ink/build/output.js b/node_modules/ink/build/output.js -index c763b77..ac81f2e 100644 +index c763b77..bfd3d08 100644 --- a/node_modules/ink/build/output.js +++ b/node_modules/ink/build/output.js @@ -1,6 +1,26 @@ @@ -886,7 +924,7 @@ index 127c620..6d5a005 100644 }) => void; export default renderNodeToOutput; diff --git a/node_modules/ink/build/render-node-to-output.js b/node_modules/ink/build/render-node-to-output.js -index f00a278..07a98d0 100644 +index f00a278..e6bc4ca 100644 --- a/node_modules/ink/build/render-node-to-output.js +++ b/node_modules/ink/build/render-node-to-output.js @@ -1,7 +1,6 @@