From 9ed18332072271b2a160c43141ebbc64d059a2ec Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Mon, 1 Jun 2026 01:46:52 +0800 Subject: [PATCH] docs(parity): record the useCursor re-assertion framework-semantic divergence (#110) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .agents/docs/ink-divergences.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/.agents/docs/ink-divergences.md b/.agents/docs/ink-divergences.md index bf81156..587da0f 100644 --- a/.agents/docs/ink-divergences.md +++ b/.agents/docs/ink-divergences.md @@ -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