From 11e9a2e68496d54973a7f625f508b9dfabadcbf7 Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Wed, 3 Jun 2026 17:45:57 +0800 Subject: [PATCH] docs(divergences): reframe activeId as the general composable-ref divergence (#132) The `useFocusManager().activeId` entry conflated two things and buried the load-bearing one. Split and reframe: - The real divergence is framework-semantic, not API-specific: a React hook re-runs each render so it can return a plain snapshot, whereas a Vue composable's setup() runs once and must wrap reactive state in a `shallowRef`. Moved to the Vue != React section as a general rule; `activeId` is now just one example of it. - Folded the empty-value convention (`null` vs Ink's `undefined`) into that entry as a Vue ecosystem idiom rather than a separate headline. Doc-only. Co-authored-by: Claude Opus 4.8 (1M context) --- .agents/docs/ink-divergences.md | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/.agents/docs/ink-divergences.md b/.agents/docs/ink-divergences.md index 79db2e3..a2f73be 100644 --- a/.agents/docs/ink-divergences.md +++ b/.agents/docs/ink-divergences.md @@ -43,16 +43,6 @@ deliberate. Divergences fall into a few kinds: - **Why:** vue-tui's renderer keeps a native host-node tree rather than a DOM emulation, so the exported node type names that tree, not a DOM node. -### `useFocusManager().activeId` empty value is `null`, not `undefined` - -- **Ink:** `useFocusManager().activeId` is a `string` and `undefined` when nothing is focused. -- **vue-tui:** `activeId` is a **`ShallowRef`** — reactive, and `null` (not - `undefined`) when nothing is focused. -- **Why:** vue surfaces focus state as a reactive ref so a template re-renders when focus - moves, and `null` is vue-tui's house convention for an empty ref (a deliberate "no value", - distinct from an unset `undefined`). Field meaning is unchanged. Test: - `focus-manager.test.tsx` ("activeId is null when nothing is focused"). - ### Second `mount()` on a live stdout is an inert no-op - **Ink:** `render()` keeps one instance per stdout (`WeakMap`); a second @@ -290,6 +280,21 @@ unsubscribe(){}}`) — a `useAnimation` rendered outside an Ink tree never ticks 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. +### Reactive composable state is a `shallowRef`, not a plain snapshot + +- **Ink/React:** a hook re-runs on every render of its component, so it can return a plain value + and the caller always reads the latest one. (`useFocusManager().activeId`, for instance, is a + bare `string | undefined`, re-read fresh each render.) +- **vue-tui:** a composable's `setup()` runs **once**, so reactive state can't be a plain + snapshot — it would freeze at setup time. vue-tui returns a **`shallowRef`** whose `.value` + updates and re-renders the template; read these as `.value`. Every stateful composable follows + this — `useTerminalSize()`, `useFocusManager().activeId`, … — and an empty one holds `null` + (Vue's convention for an empty ref: a template ref is `ref(null)`), where Ink's plain + value is `undefined`. +- **Why:** the two frameworks track a changing value differently — React reads the newest value + by re-running the hook, Vue wraps it in a ref the template subscribes to. This is the general + rule, not a per-API choice; `useFocusManager().activeId` is just one instance. + ### Out-of-type style values are forwarded, not defensively coerced - **Ink:** several flex/align setters coerce a runtime junk value to a default — `flexShrink`