Skip to content

Keep onKittyQueryResponse stable across rerender() calls - #1007

Merged
sindresorhus merged 3 commits into
vadimdemedes:masterfrom
costajohnt:stable-kitty-query-response
Sep 20, 2026
Merged

sindresorhus merged 3 commits into
vadimdemedes:masterfrom
costajohnt:stable-kitty-query-response

Conversation

@costajohnt

@costajohnt costajohnt commented Sep 19, 2026 •

Copy link
Copy Markdown
Contributor

Ink.render() passed onKittyQueryResponse to App as an inline arrow function, so it was a new function on every call, including every public rerender(). In App, that prop is a dependency of handleReadable, which feeds attachReadableListener and handleSetRawMode, so every rerender() changed the setRawMode identity in StdinContext. Every mounted useInput, usePaste and useFocus then re-ran its raw-mode effect (setRawMode(false) then setRawMode(true)), and the count-hits-zero path called clearInputState(): the input parser was reset, the pending-escape flush timer cancelled and the readable listener detached and re-attached.

Anything buffered in the parser at that moment was lost:

  • a pending Escape keypress never arrived;
  • a split escape sequence (ESC[ then A) arrived as the plain key A with shift: true instead of upArrow;
  • a bracketed paste spanning two stdin reads arrived in useInput as part2 and [201~, even with usePaste mounted.

In-tree setState re-renders do not trigger it, only rerender() does, and it happens whether or not kittyKeyboard is configured.

Make the callback a stable class property. Side effect: useStdin consumers no longer re-render on every rerender(), since the context value is stable again.

Tests (test/rerender-input.tsx): a pending Escape survives rerender(), an escape sequence split across two reads survives rerender(), a paste split across two reads survives rerender(), and rerender() does not remove the readable listener. All four fail on master.

Fixes #1009

`Ink.render()` passed `onKittyQueryResponse` as a new arrow function on
every call. The prop is a dependency of `handleReadable`, which feeds
`attachReadableListener` and `handleSetRawMode`, so every `rerender()`
changed the `setRawMode` identity in `StdinContext`. Every `useInput`
and `usePaste` then re-ran its raw-mode effect, and the count-hits-zero
path reset the input parser, cancelled the pending-escape flush and
detached the `readable` listener. A pending Escape was lost, a split
escape sequence became a plain key, and a bracketed paste spanning two
reads was delivered to `useInput` as `part2` + `[201~` even with
`usePaste` mounted.

Use a stable class property instead.
@costajohnt
costajohnt force-pushed the stable-kitty-query-response branch from 30061e2 to 1128794 Compare September 19, 2026 21:53
@sindresorhus

Copy link
Copy Markdown
Collaborator

The implementation looks right to me. The callback belongs to the long-lived Ink instance and reads the current detection resolver when called, so making it a stable instance property is the smallest fix. Handling an unstable parent callback inside App would need extra ref or effect plumbing and introduce stale-callback timing concerns. useEffectEvent is not a better fit here because it is intentionally unstable and restricted to Effect-owned calls.

The new tests need one adjustment, though. Readable.push() plus a 2 ms sleep does not guarantee that the first chunk reached the parser before rerender(), so the buggy implementation can sometimes pass without exercising the bug. The sleep after rerender() can also cross the parser's 20 ms escape timeout under load, making the fixed implementation fail. I reproduced the latter locally.

Please use the existing synchronous stdin helper to deliver the chunks. Fake timers are only needed for the pending Escape timeout. For the split sequence, emit A immediately after the synchronous rerender(), and the paste test can also stay fully synchronous. That should make the tests deterministc.

@sindresorhus
sindresorhus merged commit 70beca5 into vadimdemedes:master Sep 20, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

rerender() resets the input parser and drops in-flight keys and pastes

2 participants