Skip to content

Commit 3bc0f45

Browse files
jimgqyuclaude
andcommitted
fix: detect and correct stale Yoga layout in cursor positioning across rounds
During the render phase, getComputedLayout() returns the layout from the previous Yoga commit. When the transcript area grows (e.g. after an assistant response), the input box moves down but the render-phase cursor computation uses the old position, placing the cursor one line too high. Fix: add a post-commit useEffect that recomputes the cursor position with fresh Yoga layout, compares against the render-phase value stored in lastPosRef, and triggers a corrective re-render when they differ. The comparison guarantees at most one extra render per layout change. Also refactored: extracted computeAbsolutePosition() helper, moved position computation from useEffect to render phase (eliminating the one-frame cursor visibility delay), and replaced setImmediate with setTimeout(0) + counter dedup to survive React re-render cycles. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 0a4b5a2 commit 3bc0f45

1 file changed

Lines changed: 197 additions & 55 deletions

File tree

packages/tui/src/compat/cursor-hooks.ts

Lines changed: 197 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -27,19 +27,16 @@
2727
* ### How the ink v7 fix works
2828
* - `useCursor()` returns `{ setCursorPosition }` which integrates with
2929
* ink's log-update cursor state.
30-
* - `useInsertionEffect` propagates the position to the context BEFORE
31-
* `onRender`, so `buildCursorSuffix` emits the correct CSI as part of
32-
* the output frame.
33-
* - Ink's `buildReturnToBottomPrefix` correctly returns the cursor to the
34-
* bottom before erasing because `cursorWasShown` is tracked accurately.
35-
*
36-
* Trade-off: coordinates are computed in useEffect (runs after
37-
* useInsertionEffect), causing a 1-frame lag. This is acceptable because
38-
* IME composition text position is read by the terminal once composition
39-
* starts, at which point the cursor has been positioned for many frames.
30+
* - The cursor position is computed during the render phase (via direct
31+
* computation in the hook body, not in useEffect). This runs BEFORE
32+
* useCursor's useInsertionEffect, so positionRef.current is set before
33+
* ink propagates it to the log-update context.
34+
* - On the first render, the Yoga node ref may not be available yet
35+
* (the ref callback fires after render). A setTimeout(0) correction
36+
* with counter-based dedup handles this case robustly.
4037
*/
4138

42-
import { useCallback, useEffect, useRef } from 'react';
39+
import { useCallback, useEffect, useRef, useState } from 'react';
4340
import { useCursor } from 'ink';
4441

4542
/**
@@ -60,6 +57,35 @@ export interface DeclaredCursorHandle {
6057
setPosition(row: number, col: number): void;
6158
}
6259

60+
// ---------------------------------------------------------------------------
61+
// Helpers
62+
// ---------------------------------------------------------------------------
63+
64+
/**
65+
* Walk up the Yoga layout tree and compute the element's absolute
66+
* position in terminal cells (0-indexed from the ink output origin).
67+
*/
68+
function computeAbsolutePosition(el: any): { absTop: number; absLeft: number } | null {
69+
if (!el?.yogaNode) return null;
70+
71+
try {
72+
let node = el.yogaNode;
73+
let absTop = 0;
74+
let absLeft = 0;
75+
76+
while (node) {
77+
const layout = node.getComputedLayout();
78+
absTop += layout.top;
79+
absLeft += layout.left;
80+
node = node.getParent?.() ?? null;
81+
}
82+
83+
return { absTop, absLeft };
84+
} catch {
85+
return null;
86+
}
87+
}
88+
6389
// ---------------------------------------------------------------------------
6490
// useDeclaredCursor
6591
// ---------------------------------------------------------------------------
@@ -72,12 +98,12 @@ export interface DeclaredCursorHandle {
7298
*
7399
* On each render the hook:
74100
* 1. Captures the Box DOM element via the ref callback.
75-
* 2. In a post-render useEffect, walks up the Yoga node tree to compute
76-
* the element's absolute position in terminal cells (0-indexed from
77-
* the ink output origin).
101+
* 2. During the render phase, walks up the Yoga node tree to compute
102+
* the element's absolute position in terminal cells.
78103
* 3. Adds the (line, column) offset from the cursor layout.
79-
* 4. Calls ink's `setCursorPosition({ x, y })` so ink's log-update
80-
* includes the correct cursor suffix in the NEXT render frame.
104+
* 4. Calls ink's `setCursorPosition` to write to the position ref,
105+
* so that useCursor's useInsertionEffect propagates it to log-update
106+
* in the same frame.
81107
*
82108
* The cursor is always positioned at the computed coordinates — we never
83109
* hide it via `setCursorPosition(undefined)`. The TextInput's nativeCursor
@@ -98,60 +124,176 @@ export function useDeclaredCursor(
98124
const { setCursorPosition } = useCursor();
99125

100126
// Track whether we have emitted the blink-enable sequence.
101-
// \x1b[?12h is a DEC private mode that persists across cursor hide/show
102-
// cycles, so we only need to emit it once per mount.
103127
const blinkEmittedRef = useRef(false);
104128

129+
// ── Counter-based dedup for first-frame Yoga layout correction ──────
130+
// The ref callback fires AFTER the first render. On the first render,
131+
// elRef.current is null, so we can't compute the cursor position during
132+
// the render phase. After the ref callback fires, the component won't
133+
// necessarily re-render (unless something else triggers it).
134+
//
135+
// We use a `refReady` state flag + setTimeout(0) correction to ensure
136+
// the cursor is positioned as soon as possible:
137+
// 1. Ref callback fires → setRefReady(true) → triggers re-render
138+
// 2. On the re-render, position is computed during render phase
139+
// 3. setTimeout(0) acts as a safety net for cases where Yoga hasn't
140+
// settled by the first re-render after ref callback
141+
//
142+
// Counter dedup: if multiple setTimeout callbacks are scheduled before
143+
// the timer phase, only the latest one takes effect.
144+
const correctionCounterRef = useRef(0);
145+
const [refReady, setRefReady] = useState(false);
146+
147+
// ── Stale Yoga layout correction ─────────────────────────────────────
148+
// During the render phase, getComputedLayout() returns the layout from
149+
// the PREVIOUS Yoga commit — not the layout that will be produced by the
150+
// CURRENT commit. When content above the input box grows (e.g. after an
151+
// assistant response), the input element's absolute position changes but
152+
// the render-phase computation uses the old (higher) position.
153+
//
154+
// The post-commit useEffect below detects this discrepancy:
155+
// 1. Render phase computes position from stale Yoga → stores in lastPosRef
156+
// 2. useInsertionEffect propagates stale position to log-update
157+
// 3. Post-commit: Yoga layout is now fresh → recompute position
158+
// 4. If position differs from lastPosRef → setCursorPosition + force re-render
159+
// 5. Re-render: render phase uses fresh Yoga → position matches → stable
160+
//
161+
// The lastPosRef comparison prevents infinite re-render loops.
162+
const lastPosRef = useRef<{ x: number; y: number } | null>(null);
163+
const [, setCorrectionTick] = useState(0);
164+
165+
// ── Compute cursor position during render phase ────────────────────
166+
// This is called during the render, BEFORE useCursor's useInsertionEffect
167+
// runs. The setCursorPosition call only writes to a ref (positionRef),
168+
// so it's safe to call during render — no state changes, no side effects.
169+
//
170+
// On the first render, elRef.current is null because the ref callback
171+
// hasn't fired yet. On subsequent renders (after the ref callback),
172+
// elRef.current is set and we can compute the position immediately.
173+
//
174+
// NOTE: getComputedLayout() returns the layout from the PREVIOUS Yoga
175+
// commit. When the input box moves (e.g. after assistant output grows),
176+
// this computation produces a stale position. The post-commit useEffect
177+
// below detects the discrepancy and triggers a correction re-render.
178+
const el = elRef.current;
179+
if (el?.yogaNode) {
180+
const pos = computeAbsolutePosition(el);
181+
if (pos) {
182+
const opts = optsRef.current;
183+
const x = pos.absLeft + (opts?.column ?? 0);
184+
const y = pos.absTop + (opts?.line ?? 0);
185+
186+
// Store for post-commit comparison (detects stale Yoga layout).
187+
lastPosRef.current = { x, y };
188+
189+
// Write to ink's positionRef synchronously during render.
190+
// useCursor's useInsertionEffect will read this and propagate
191+
// it to the log-update cursor context in the same frame.
192+
setCursorPosition({ x, y });
193+
194+
// Enable cursor blinking on the first successful position computation.
195+
if (!blinkEmittedRef.current && process.stdout.isTTY) {
196+
process.stdout.write('\x1b[?12h');
197+
blinkEmittedRef.current = true;
198+
}
199+
}
200+
}
201+
202+
// ── First-frame Yoga layout correction via setTimeout ──────────────
203+
// Schedule a correction after the current render cycle completes.
204+
// Uses setTimeout(0) instead of setImmediate because:
205+
// - setImmediate callbacks live in the check phase and can be canceled
206+
// by clearImmediate() in useEffect cleanup during React re-renders
207+
// - setTimeout(0) fires in the timer phase and survives React's
208+
// effect cleanup cycle
209+
//
210+
// The correction callback re-computes the position using the latest
211+
// Yoga layout and the latest cursor opts. Counter dedup ensures only
212+
// the latest scheduled correction actually takes effect.
105213
useEffect(() => {
106214
const opts = optsRef.current;
107215
if (!process.stdout.isTTY) return;
108216

109-
// Enable cursor blinking on the first successful render frame.
110-
// Ink v7's buildCursorSuffix only emits \x1b[?25h (DECTCEM show cursor),
111-
// never a blink sequence. ?12h is the DEC "Start Blinking Cursor" mode
112-
// and is widely supported (xterm, iTerm2, Windows Terminal). Terminals
113-
// that ignore ?12h (kitty, Alacritty) control blinking via user config.
114-
if (!blinkEmittedRef.current) {
115-
process.stdout.write('\x1b[?12h');
116-
blinkEmittedRef.current = true;
117-
}
217+
const correctionId = ++correctionCounterRef.current;
118218

119-
const el = elRef.current;
120-
if (!el?.yogaNode) return;
219+
const timerId = setTimeout(() => {
220+
// Only the latest correction takes effect
221+
if (correctionId !== correctionCounterRef.current) return;
121222

122-
try {
123-
// Walk up the Yoga layout tree, summing top / left offsets to
124-
// compute the element's absolute position in terminal cells.
125-
let node = el.yogaNode;
126-
let absTop = 0;
127-
let absLeft = 0;
128-
129-
while (node) {
130-
const layout = node.getComputedLayout();
131-
absTop += layout.top;
132-
absLeft += layout.left;
133-
node = node.getParent?.() ?? null;
134-
}
223+
const el2 = elRef.current;
224+
const pos = computeAbsolutePosition(el2);
225+
if (!pos) return;
135226

136-
// Ink's CursorPosition is 0-indexed from the ink output origin.
137-
// absTop/absLeft from the root Yoga node ARE the ink-relative
138-
// offsets (the Yoga root represents the entire ink output).
139-
// The +1 on y accounts for the terminal row offset between the
140-
// Yoga layout origin and the first ink-rendered row.
227+
const latestOpts = optsRef.current;
141228
setCursorPosition({
142-
x: absLeft + (opts?.column ?? 0),
143-
y: absTop + (opts?.line ?? 0) + 1,
229+
x: pos.absLeft + (latestOpts?.column ?? 0),
230+
y: pos.absTop + (latestOpts?.line ?? 0),
144231
});
145-
} catch {
146-
// Yoga layout not yet computed — silently skip this frame.
147-
// The next render will retry once Yoga has calculated positions.
232+
233+
// Also trigger a re-render so useInsertionEffect propagates the
234+
// position to log-update on the next frame.
235+
if (!refReady) {
236+
setRefReady(true);
237+
}
238+
}, 0);
239+
240+
// NOTE: Intentionally NOT canceling the timeout in cleanup.
241+
// The counter dedup mechanism above handles stale corrections.
242+
// Canceling in cleanup would reintroduce the same bug
243+
// (setImmediate + clearImmediate being defeated by re-renders).
244+
});
245+
246+
// ── Post-commit stale Yoga layout correction ──────────────────────
247+
// This useEffect (no deps) runs after EVERY commit with fresh Yoga layout.
248+
// It detects when the input element's absolute position changed between the
249+
// previous and current Yoga commits — a scenario the render phase cannot
250+
// detect because getComputedLayout() returns stale (pre-commit) values.
251+
//
252+
// Flow when layout changes:
253+
// 1. Render phase: lastPosRef = stalePos, setCursorPosition(stalePos)
254+
// 2. useInsertionEffect propagates stalePos to log-update → wrong cursor
255+
// 3. Post-commit useEffect: Yoga fresh → compute freshPos
256+
// 4. freshPos !== lastPosRef → setCursorPosition(freshPos) + setCorrectionTick
257+
// 5. Next render: render phase uses fresh Yoga → freshPos === lastPosRef
258+
// 6. useInsertionEffect propagates freshPos → correct cursor
259+
// 7. Post-commit useEffect: freshPos === lastPosRef → no-op → stable
260+
//
261+
// Counter state (correctionTick) is used instead of a boolean flag because
262+
// React may batch multiple setState(false) calls and skip the re-render.
263+
// An incrementing counter guarantees each correction triggers a distinct
264+
// state transition.
265+
useEffect(() => {
266+
const el = elRef.current;
267+
if (!el?.yogaNode) return;
268+
269+
const pos = computeAbsolutePosition(el);
270+
if (!pos) return;
271+
272+
const opts = optsRef.current;
273+
const x = pos.absLeft + (opts?.column ?? 0);
274+
const y = pos.absTop + (opts?.line ?? 0);
275+
276+
const last = lastPosRef.current;
277+
if (!last || last.x !== x || last.y !== y) {
278+
// Yoga layout shifted post-commit — update cursor ref and force
279+
// a re-render so useCursor's useInsertionEffect propagates the
280+
// corrected position to log-update.
281+
setCursorPosition({ x, y });
282+
setCorrectionTick((t) => t + 1);
148283
}
149284
});
150285

151-
// Ref-setter callback — captures the Box element for the effect above.
152-
return useCallback((el: any) => {
286+
// ── Ref callback ──────────────────────────────────────────────────
287+
// Triggers a re-render (via refReady state) when the ref is first set,
288+
// so the render-phase position computation can run with the Yoga node.
289+
const boxRefCallback = useCallback((el: any) => {
153290
elRef.current = el;
154-
}, []);
291+
if (el && !refReady) {
292+
setRefReady(true);
293+
}
294+
}, [refReady]);
295+
296+
return boxRefCallback;
155297
}
156298

157299
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)