fix: resolve keyboard freeze via sync render path and stable useAppState selectors (#266)

* fix: resolve keyboard freeze via sync render path and stable useAppState selectors

Two compounding React 19 defects caused keyboard input to freeze after
MCP notifications or rapid state updates:

Defect 2 (ink.tsx): The render() path used async updateContainer, which
batches updates across scheduler ticks. Keyboard events dispatched mid-render
drained faster than React processed them, causing input to appear frozen.
Fixed by switching to updateContainerSync + flushSyncWork (same pattern
already used in the unmount path).

Defect 4 (AppState.tsx): useAppState and useAppStateMaybeOutsideOfProvider
used React Compiler _c cache invalidation tied to selector identity. Inline
arrow selectors (new reference each render) invalidated the cache every cycle,
producing a new `get` function. useSyncExternalStore treats a new `get` as a
tearing signal, re-syncing state and re-rendering — causing a loop that
starved the input handler. Fixed with useRef + useCallback(fn, []) to give
useSyncExternalStore a permanently stable snapshot reference.

Note: AppState.tsx is React Compiler output. The _c bypass for these two
hooks is intentional — compiler cache invalidation on inline selectors is
the root cause of the tearing loop.

All 200 tests pass. Build and smoke test verified.

Closes #77, #220, #228

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: update selector refs during render instead of useLayoutEffect

Addresses review feedback on PR #266. The previous useLayoutEffect approach
updated selectorRef.current after the render phase, meaning a changed selector
(e.g. s => s.tasks[attachment.taskId] when taskId changes) would still read
stale data during the render it changed in.

Fix: assign selectorRef.current and storeRef.current directly during render
before useSyncExternalStore calls get(). Ref mutation during render is safe
here — it's synchronous and happens before the snapshot is read. get()
identity stays stable via useCallback(fn, []) so useSyncExternalStore never
sees a new subscription function and won't trigger re-render loops.

This is the standard pattern used by zustand and jotai for selector stability.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
KRATOS
2026-04-03 20:03:16 +05:30
committed by GitHub
parent b0d796e5c3
commit c1e5e363cd
2 changed files with 24 additions and 30 deletions

View File

@@ -1474,7 +1474,10 @@ export default class Ink {
</TerminalWriteProvider>
</App>;
reconciler.updateContainer(tree, this.container, null, noop);
// @ts-expect-error updateContainerSync exists in react-reconciler but not in @types/react-reconciler
reconciler.updateContainerSync(tree, this.container, null, noop);
// @ts-expect-error flushSyncWork exists in react-reconciler but not in @types/react-reconciler
reconciler.flushSyncWork();
logForDebugging('[Ink:render] updateContainer complete');
}
unmount(error?: Error | number | null): void {

File diff suppressed because one or more lines are too long