docs(parity): record the round-2 deliberate divergences from Ink (#111)
The round-2 parity audit surfaced ~15 places where vue-tui deliberately differs from Ink v7.0.4 (verified against Ink source, not invented). Document them so they aren't re-"fixed" back into Ink's behavior: Public API surface: useFocusManager().activeId is null (reactive ShallowRef) not undefined (+ lock test); second mount() on a live stdout is an inert no-op vs Ink reusing the instance; bare-string package exports vs an explicit types condition. Additive (strict superset): RGB [r,g,b] tuples on every color prop (Ink string-only, throws on an array); backgroundColor=chalk-modifier-name degrades to bare text (Ink throws); useAnimation outside a tree drives a real scheduler; measureElement/ useBoxMetrics also accept a Vue component ref via $el; renderToString accepts isScreenReaderEnabled; narrowing resize cancels the redundant trailing clearTerminal. Framework-semantic (Vue ≠ React): an off-spec display value stays visible (Ink hides any non-'flex'); out-of-type flex/align values are forwarded not defensively coerced (only flexShrink — flexGrow matches Ink; reachable only via TS-bypass); duplicate explicit-id useFocus dedups to one entry; the terminal-bound composables fail fast outside a tree (useBoxMetrics/useAnimation degrade); a setup()-throw emits a dev-only [Vue warn]. Plus wrapText truncate's per-line short-circuit and the scheduler's ceil'd delay under reconciler mechanics. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -43,6 +43,43 @@ 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<string | null>`** — 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<WriteStream, Ink>`); a second
|
||||
`render(node, {stdout})` on a stream that already has a live instance warns on stderr but
|
||||
**reuses** that instance and `rerender`s the new tree into it.
|
||||
- **vue-tui:** a second `mount()` on a still-live stdout warns on stderr and returns an
|
||||
**inert handle** — it wires no second renderer and renders nothing; the first app's tree
|
||||
stays on screen. `unmount()`/`teardown()` on that handle are complete no-ops (they never
|
||||
touch the owner's stream or registry entry).
|
||||
- **Why:** a direct consequence of the `createApp()`-vs-`render()` model — an app is an object
|
||||
you `mount()`, not a one-shot call that doubles as a re-render. "Re-render the live instance"
|
||||
has no place to land when the second call is a separate `TuiApp`; the correct path is
|
||||
`unmount()` then mount again (or keep one app and update its reactive state). Failing closed
|
||||
(no competing renderer on the shared stream) over silently hijacking the output is the
|
||||
principled choice. Test: `instance-reuse-guard.test.tsx`.
|
||||
|
||||
### Package `.` exports use a bare-string target, not an explicit `types` condition
|
||||
|
||||
- **Ink:** `package.json` `exports` uses an explicit `types` condition (`{"types": …,
|
||||
"default": …}`).
|
||||
- **vue-tui:** runtime/testing `.` (and `./internal`) exports are a **bare string**
|
||||
(`"./dist/index.mjs"`) — TS resolves the declaration via the `.d.mts`-next-to-`.mjs`
|
||||
adjacency tsdown emits (`index.d.mts` beside `index.mjs`).
|
||||
- **Why:** with the adjacency present, an explicit `types` condition is redundant. Noted so a
|
||||
future reader does **not** "restore parity" by adding a `types` condition the toolchain
|
||||
already satisfies. (The `cli` package needs no `types` at all — it has no public type surface.)
|
||||
|
||||
## Additive features (vue-tui is a strict superset)
|
||||
|
||||
### Multiple `<Static>` regions
|
||||
@@ -93,6 +130,76 @@ deliberate. Divergences fall into a few kinds:
|
||||
`new Error(String(value))`, which also produced a misleading synthetic stack pointing at the
|
||||
framework internals (that synthetic stack is now gone). Introduced 2026-05-31.
|
||||
|
||||
### RGB `[r, g, b]` tuples on every color prop
|
||||
|
||||
- **Ink:** all color props (`<Text>` color/backgroundColor, `<Box>` backgroundColor, and every
|
||||
border color/background prop) are **string-only** — `colorize`/`stylePiece` call
|
||||
`color.startsWith('#')`, so passing an array **throws** (`.startsWith` is not a function).
|
||||
- **vue-tui:** the public `Color` type is `string | [number, number, number]`; `applyColor`
|
||||
handles an array via `chalk.rgb(...)` / `chalk.bgRgb(...)`. Accepted uniformly on Text color,
|
||||
Text/Box backgroundColor, and all border color/background props.
|
||||
- **Why:** a strict superset — every string Ink accepts still works, plus an ergonomic RGB
|
||||
tuple. The tuple is part of the typed surface (not a TS-bypass), so it's a supported input,
|
||||
not undefined behavior. Tested.
|
||||
|
||||
### `backgroundColor` of a chalk modifier name degrades to bare text
|
||||
|
||||
- **Ink:** `backgroundColor='bold'` (any chalk **modifier** name, not a color) resolves
|
||||
`isNamedColor('bold')` true (`'bold' in chalk`), then calls `chalk['bgBold']` — which doesn't
|
||||
exist — and **throws** ("chalk.bgBold is not a function").
|
||||
- **vue-tui:** `applyColor`'s `typeof named === 'function'` guard sees `chalk['bgBold']` is
|
||||
`undefined`, falls through `#`/`ansi256`/`rgb` (all non-matching), and returns the text
|
||||
**unstyled** — no SGR, no throw.
|
||||
- **Why:** same fallback policy vue already applies to an unparseable `ansi256(...)`/`rgb(...)`
|
||||
string ("no match → bare text"). A non-color background name is junk input; degrading to bare
|
||||
text is more robust than crashing the render. Additive robustness.
|
||||
|
||||
### `useAnimation()` outside a render tree drives a real standalone animation
|
||||
|
||||
- **Ink:** the default `AnimationContext.subscribe()` is a no-op (`{startTime: 0,
|
||||
unsubscribe(){}}`) — a `useAnimation` rendered outside an Ink tree never ticks.
|
||||
- **vue-tui:** `useAnimation` falls back to a freshly created standalone scheduler
|
||||
(`inject(AnimationSchedulerKey, null) ?? createAnimationScheduler()`), so `frame`/`time`/
|
||||
`delta` actually advance even with no surrounding app.
|
||||
- **Why:** graceful degradation over a silent dead animation — the composable still does
|
||||
something useful in isolation (e.g. a unit test, or a non-rendered driver). Additive; inside
|
||||
a tree the injected scheduler is used exactly as Ink's. (Contrast with the terminal-bound
|
||||
composables in the fail-fast section, which throw — those have no meaningful standalone mode.)
|
||||
|
||||
### `measureElement` / `useBoxMetrics` also accept a Vue component-instance ref
|
||||
|
||||
- **Ink:** `measureElement(node: DOMElement)` and `useBoxMetrics(ref: RefObject<DOMElement>)`
|
||||
read `node.yogaNode` directly — a host `DOMElement` only.
|
||||
- **vue-tui:** the ref is resolved through `$el` as well — a `ref` bound to a **Vue component**
|
||||
(whose root host node is on `$el`), not just a host-node ref, resolves to the underlying yoga
|
||||
node.
|
||||
- **Why:** in Vue a template ref on a component yields the component instance, and its host node
|
||||
is reached via `$el`. Supporting both shapes is a strict superset that matches how Vue refs
|
||||
actually behave; a bare host-node ref still works identically to Ink.
|
||||
|
||||
### `renderToString` supports screen-reader mode
|
||||
|
||||
- **Ink:** `renderToString` has only a `columns` option; it always renders the non-SR (ANSI)
|
||||
frame.
|
||||
- **vue-tui:** `renderToString` accepts `isScreenReaderEnabled?: boolean`. In SR mode it returns
|
||||
the linearized accessibility text (`renderScreenReaderOutput`) and prepends the linearized
|
||||
`<Static>` output, just as the non-SR path prepends the painted static frame.
|
||||
- **Why:** vue-tui already has a parity SR renderer for the live path; surfacing it through the
|
||||
string API is a strict superset (default `false` is byte-identical to Ink) and avoids
|
||||
silently dropping `<Static>` content when generating SR snapshots. Additive.
|
||||
|
||||
### Narrowing resize cancels the redundant trailing `clearTerminal`
|
||||
|
||||
- **Ink:** `resized()` paints synchronously via `onRender()` but does **not** cancel a pending
|
||||
throttled `onRender`; on a narrowing resize that trailing commit re-runs and, because
|
||||
`shouldClearTerminalForFrame` clears whenever the previous frame overflowed, Ink emits a
|
||||
**second** `clearTerminal`.
|
||||
- **vue-tui:** `onResize` calls `scheduler.cancel()` before its synchronous commit, dropping the
|
||||
now-redundant trailing commit — so the screen is cleared **once** per narrowing resize.
|
||||
- **Why:** the synchronous resize commit already reflects the current tree, so the pending
|
||||
commit is pure duplication; emitting one clear instead of two is strictly cleaner with no
|
||||
visible difference (issue #26). Additive robustness.
|
||||
|
||||
## Framework-semantic divergences (Vue ≠ React)
|
||||
|
||||
### Removing `flexDirection` / `flexWrap` resets to the default
|
||||
@@ -140,6 +247,72 @@ deliberate. Divergences fall into a few kinds:
|
||||
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.
|
||||
|
||||
### An off-spec `display` value stays visible instead of hiding
|
||||
|
||||
- **Ink:** `applyDisplayStyles` sets `DISPLAY_NONE` for **any** present `display` that isn't
|
||||
`'flex'` — so a typo or off-spec value (`display="block"`, `display=""`, reachable via a
|
||||
TS-bypass) **hides** the box.
|
||||
- **vue-tui:** `toDisplay` hides only on the exact value `'none'`; every other value (including
|
||||
off-spec) falls back to the visible default `DISPLAY_FLEX`.
|
||||
- **Why:** an unknown/typo `display` shouldn't silently delete content — failing visible is the
|
||||
safer default. It's also consistent with the removal-reset above: a withdrawn `display` returns
|
||||
to visible, and so does an unrecognized one. (The only honored hide is the documented `'none'`.)
|
||||
|
||||
### Out-of-type style values are forwarded, not defensively coerced
|
||||
|
||||
- **Ink:** several flex/align setters coerce a runtime junk value to a default — `flexShrink`
|
||||
non-number → `1`; `alignItems`/`alignSelf`/`alignContent`/`justifyContent` falsy (`""`) →
|
||||
their default (STRETCH / AUTO / FLEX_START); and an out-of-set value matches none of Ink's
|
||||
`if`-chain branches, so no setter runs and the previous/default value persists.
|
||||
- **vue-tui:** these setters trust the typed prop surface and forward the raw value to yoga:
|
||||
a non-number `flexShrink` is passed through; `toAlign("")`/`toJustify("")` look up `""` and
|
||||
pass `undefined` to the setter; and out-of-set values that yoga happens to accept
|
||||
(`space-*`/`baseline`/`auto` on `alignItems`) reach yoga rather than being ignored.
|
||||
- **Why:** every one of these is reachable **only** via a TS-bypass — the public prop types
|
||||
forbid them. Within the typed contract Ink and vue-tui are identical. Ink's per-value
|
||||
coercion is defensive code for runtime junk vue-tui's types already exclude; duplicating a
|
||||
family of `typeof`/falsy guards for inputs the type system rejects buys nothing. (`flexGrow`
|
||||
is not in this set — both only coerce null/undefined → `0`.) If a reviewer shows any case is
|
||||
reachable in-type, it becomes a bug to fix, not a divergence.
|
||||
|
||||
### Duplicate explicit-`id` `useFocus` calls dedup to one registry entry
|
||||
|
||||
- **Ink:** `addFocusable` unconditionally appends, so two `useFocus({id: 'x'})` create **two**
|
||||
focusables with the same id — Tab visits "x" twice, and unmounting one calls `removeFocusable`
|
||||
which filters by id and removes **both**.
|
||||
- **vue-tui:** `add(id)` is id-keyed (`if (!focusables.some(f => f.id === id))`), so a duplicate
|
||||
explicit id registers **one** entry.
|
||||
- **Why:** an id-keyed registry is the principled model — an id identifies one focusable, so Tab
|
||||
visiting a duplicate twice and one unmount silently dropping the other are Ink anomalies, not
|
||||
contracts. Auto-generated ids never collide, so this only differs for an explicit duplicate id
|
||||
(already a user error).
|
||||
|
||||
### Composables fail fast outside a render tree
|
||||
|
||||
- **Ink:** the hooks read a React context whose **default** value is a no-op object, so calling
|
||||
e.g. `useStdin()` outside an Ink tree returns inert defaults silently.
|
||||
- **vue-tui:** `useApp`, `useStdout`, `useStderr`, `useStdin`, `useTerminalSize`, `useFocus`,
|
||||
`useFocusManager`, `useInput`, `usePaste`, `useCursor`, and `useIsScreenReaderEnabled`
|
||||
**throw** when their context is absent ("… must be called inside a vue-tui render tree").
|
||||
(`useBoxMetrics` and `useAnimation` do **not** throw — they degrade: `useBoxMetrics` reports
|
||||
zero metrics, `useAnimation` drives a real standalone scheduler. See the additive entry.)
|
||||
- **Why:** fail-fast beats a silent footgun — a composable used in the wrong place is a bug, and
|
||||
a thrown error names it at the call site instead of returning a context that quietly does
|
||||
nothing. The two exceptions degrade because they have a meaningful standalone behavior (a
|
||||
measurable-zero / a working animation), so throwing would remove a useful capability.
|
||||
|
||||
### A `setup()`-throwing component emits a dev-only `[Vue warn]` on stderr
|
||||
|
||||
- **Ink:** a component that throws during render surfaces only through the error overview /
|
||||
exit path; React emits no extra framework warning.
|
||||
- **vue-tui:** in a **development** build, a component whose `setup()` throws additionally
|
||||
produces Vue's own `[Vue warn]` lines on stderr (e.g. the missing-render-function warning)
|
||||
that Ink has no analog for. In interactive mode patchConsole filters `[Vue warn]` out of the
|
||||
frame; outside that path (debug, non-patched stderr) it surfaces.
|
||||
- **Why:** these warnings come from Vue itself and are **dev-only** (stripped in production
|
||||
builds); they have no effect on stdout output or the exit code. Documented so the stray
|
||||
warn isn't mistaken for a vue-tui behavior — it's Vue's framework diagnostics.
|
||||
|
||||
## Not applicable in Vue
|
||||
|
||||
### React concurrent mode
|
||||
@@ -181,6 +354,18 @@ built never reaches the terminal:
|
||||
re-renders are Vue's fine-grained reactivity, not a React subtree re-render.
|
||||
- **Keyed lists use Vue core's `patchKeyedChildren`** (LIS), not React's fiber diff; output
|
||||
depends on the final tree, not the move order.
|
||||
- **`wrapText` truncate has a per-line short-circuit** before its whole-string `cli-truncate`:
|
||||
if every `\n`-split line already fits `width` it returns the lines unchanged, otherwise it
|
||||
truncates the whole string once (as Ink does). Ink instead gates at paint time on the
|
||||
**widest line** (`widestLine(text) > maxWidth`) before calling `wrapText`, which then
|
||||
whole-string-truncates with no per-line check. The two paint-time gates (vue's per-line
|
||||
`every`, Ink's widest-line) admit the same multi-line texts in practice, so production output
|
||||
matches — documented so the divergent short-circuit branch isn't "fixed" to bare whole-string
|
||||
truncate (which would collapse perfectly-fitting multi-line text to one line).
|
||||
- **The animation scheduler rounds the `setTimeout` delay up** (`Math.ceil(earliest - now)`)
|
||||
where Ink passes the raw fractional delay. `setTimeout` truncates a fractional delay and would
|
||||
fire early, re-skip (`now < nextDueTime`), and reschedule a ~0 ms delay — a sub-ms busy-loop.
|
||||
Non-behavioral (Node coerces the delay to an int anyway); the in-code comment explains it.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -40,3 +40,25 @@ test("useFocusManager().activeId tracks the currently focused component", async
|
||||
await stdin.write("\t");
|
||||
expect(activeId.value).toBe("a");
|
||||
});
|
||||
|
||||
// Locks the vue API-surface sentinel: `activeId` is a ShallowRef whose EMPTY
|
||||
// value is `null` (Ink's equivalent is `undefined`). See ink-divergences.md
|
||||
// ("`useFocusManager().activeId` empty value is `null`, not `undefined`").
|
||||
test("useFocusManager().activeId is null when nothing is focused", async () => {
|
||||
let activeId!: ReturnType<typeof useFocusManager>["activeId"];
|
||||
|
||||
const App = defineComponent(() => {
|
||||
const manager = useFocusManager();
|
||||
activeId = manager.activeId;
|
||||
// No <useFocus> children → no focusables → nothing active.
|
||||
return () => (
|
||||
<Box>
|
||||
<Text>no focusables</Text>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
await render(App);
|
||||
|
||||
expect(activeId.value).toBeNull();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user