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' ;
4340import { 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