docs(parity): record the useCursor re-assertion framework-semantic divergence (#110)

Investigating P15 (a stable-reference cursor dropped on an unrelated commit) found
the premise was false: Ink does NOT re-assert the cursor on every commit. Ink's
useCursor uses a no-deps useInsertionEffect that re-runs only when the cursor
COMPONENT re-renders. React re-renders a whole subtree on an ancestor's commit, so
Ink re-asserts when the cursor is in that subtree — but when an unrelated SIBLING
owns the changing state, the cursor component does not re-render and Ink drops the
cursor too. vue (watch on positionRef) already matches Ink in that sibling case and
for the recommended reactive usage; the two differ only in the narrow edge of a
set-once cursor plus an ancestor-driven commit (Vue's fine-grained reactivity vs
React's render cascade). A global per-commit re-assert would diverge from Ink in the
opposite (sibling) direction. So this is an unavoidable Vue ≠ React consequence:
document it and keep the reactivity-tied behavior rather than "fix" it.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yunfei He
2026-06-01 01:46:52 +08:00
committed by GitHub
parent 99839397c6
commit 9ed1833207
+23
View File
@@ -117,6 +117,29 @@ deliberate. Divergences fall into a few kinds:
f(current props): no `display` set → the default (visible). Persisting a withdrawn prop,
or flipping it to hidden, is the anomaly. Maintainer decision (2026-05-31): KEEP.
### `useCursor()` re-assertion follows fine-grained reactivity, not React's render cascade
- **Ink:** `useCursor`'s no-deps `useInsertionEffect` (`use-cursor.ts:27-32`) re-runs on every
render **of the cursor component**, re-marking the cursor dirty (`ink.tsx:494-497`); log-update
resets `cursorDirty` each commit. React re-renders a whole subtree when an ancestor commits, so
if the cursor component is in that subtree it re-renders and the cursor is re-asserted — even
when only an ancestor's unrelated state changed. (If an _unrelated sibling_ owns the changing
state, the cursor component does **not** re-render, so Ink does **not** re-assert and the cursor
is dropped that commit.)
- **vue-tui:** `useCursor` propagates via `watch(positionRef, …, {flush:'sync'})` — it re-asserts
when the position **reference changes** (or the owning component re-renders and re-sets it). Vue's
fine-grained reactivity re-runs only components whose own deps changed, so an _ancestor_-driven
commit does **not** re-run a cursor child that didn't depend on the changed value, and a
set-once cursor is dropped that commit.
- **Why:** the two agree for the **recommended** usage — set the position reactively (in the render
body / from a ref the component reads), as Ink's apps and vue-tui's parity tests do — and they
agree in the unrelated-sibling case (both drop). They differ only in the narrow edge of a
**set-once** cursor plus an **ancestor-driven** commit: React's render cascade re-asserts it,
Vue's fine-grained reactivity does not. This is a direct consequence of Vue ≠ React (cascade vs
fine-grained re-render) and cannot be papered over: a global per-commit re-assert would make vue
diverge from Ink in the _opposite_ (unrelated-sibling) direction, where Ink drops the cursor.
Keep the reactivity-tied behavior. Maintainer decision (2026-06-01): KEEP.
## Not applicable in Vue
### React concurrent mode