docs(divergences): correct ledger after adversarial re-audit (#138)

Re-verified every ledger entry against Ink v7.0.4. Fixes: MI5 (remove false
"residual false-child divergence", verified by running Ink), MI3 (Model-Implied
-> Intentional), VI2 (-> Non-Behavioral), VI3 (-> Intentional), and an
error-overview test comment (Ink renders blank error.message, not String(value)).
ID4 verified accurate and left unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yunfei He
2026-06-05 08:49:53 +08:00
committed by GitHub
parent 7f3b0ca40e
commit a31e8fa335
2 changed files with 58 additions and 73 deletions
+54 -71
View File
@@ -217,30 +217,6 @@ reactivity, lifecycle, component boundaries, current-props model, or API convent
Ink drops the cursor. Keep the reactivity-tied behavior. Maintainer decision Ink drops the cursor. Keep the reactivity-tied behavior. Maintainer decision
(2026-06-01): KEEP. (2026-06-01): KEEP.
#### Invalid input is validated at the component layer, not the paint layer
- **Principle:** vue-tui validates invalid render input (a chalk-**modifier**
`backgroundColor` like `"bold"`, an unknown `borderStyle`) at the
**component-render layer** (`box.ts` / `text.ts`), not down at the **paint layer**. A bad
value therefore throws where the **error boundary** catches it -> `ErrorOverview` -> a
clean `reject` of `waitUntilExit()`, exactly like any other component error. The app
reports the error instead of crashing.
- **Ink:** validates the same inputs **lazily at paint** (`colorize` / `render-border`, run
from the reconciler's commit hook): **outside** React's ErrorBoundary, so a bad value is
an uncaught crash, not a recoverable error.
- **Why:** the key constraint is where paint runs. vue-tui's paint runs in a Vue
**post-flush callback** (`queuePostFlushCb`, decoupled from render), so a throw there
escapes `onErrorCaptured` and wedges the scheduler. Unlike a component error, it cannot
be made recoverable. The escape itself is symmetric, not a Vue weakness: a component
error boundary (React `ErrorBoundary`; vue-tui's `onErrorCaptured` wrapper) covers
framework-managed component work, never the renderer's paint callbacks, so a paint-layer
throw is uncatchable in **both** engines. Validating in `Box` / `Text` keeps a bad value
on that boundary-driven recoverable path; Ink's paint-time check can only crash.
- **Cost:** the component-layer check is eager (no paint-time layout/squash info), so it
over-throws in a few degenerate, invalid-input-only cases Ink never reaches. Realistic
inputs match Ink; both error on bad input. Only the channel (recoverable reject vs crash)
differs. Tests: `background-color.test.tsx`, plus the `borderStyle` validation tests.
#### A `setup()`-throwing component emits a dev-only `[Vue warn]` on stderr #### 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 / - **Ink:** a component that throws during render surfaces only through the error overview /
@@ -253,27 +229,6 @@ reactivity, lifecycle, component boundaries, current-props model, or API convent
builds); they have no effect on stdout output or the exit code. Documented so the stray builds); they have no effect on stdout output or the exit code. Documented so the stray
warn is not mistaken for vue-tui behavior: it is Vue's framework diagnostics. warn is not mistaken for vue-tui behavior: it is Vue's framework diagnostics.
#### Vue comment placeholders are inert host nodes, with one residual `false`-child divergence
- **Ink:** React emits no host node for `null` / `false` / `undefined` children in the
ordinary cases, so those children do not affect layout or transform indexing.
- **vue-tui:** a `null` / `false` / `undefined` child or a `v-if="false"` branch is
materialized by `@vue/runtime-core` as a **comment vnode**. That comment is the position
anchor Vue uses to refill its slot when the condition flips back. vue-tui's host
renderer creates a `TuiComment` for it, and makes that node inert: no yoga node, paints
nothing, never shifts a sibling's yoga index, and is skipped when counting the positional
`<Transform>` index (the `if (child.type !== "comment") index++` guards across all three
squash paths: top-level paint, nested transform, screen-reader; `G52`).
- **Why:** comment anchors are part of Vue's update model. The renderer must preserve the
anchor while making it output-inert, so the terminal result equals omitting the element in
the common `null` / `v-if` cases. `<Transform>` follows the same model: a slot that is
empty or all-comments renders **no node** (`return null`), matching Ink's
`children == null` guard for common `{null}` / `{cond ? x : null}` idioms.
- **Residual divergence:** a literal `{false}` / `{cond && x}`-false child differs. React
keeps `false !== null`, so Ink renders an empty node (a gap slot in a flex-gap container).
Vue collapses `false` and `null` into the same `TuiComment` and omits it. That gap-slot
mismatch is the documented cost of using one comment-anchor model everywhere.
#### React concurrent mode #### React concurrent mode
- **Ink:** built on React; Suspense / `useTransition` are React features. - **Ink:** built on React; Suspense / `useTransition` are React features.
@@ -293,28 +248,6 @@ reactivity, lifecycle, component boundaries, current-props model, or API convent
app handle are therefore Vue-shaped (`MountOptions` / `TuiApp`), not `render()`-shaped app handle are therefore Vue-shaped (`MountOptions` / `TuiApp`), not `render()`-shaped
(`RenderOptions` / `Instance`). (`RenderOptions` / `Instance`).
#### Host-node type - `DOMElement` -> `TuiNode`
- **Ink:** exports `DOMElement`, a DOM-emulation node (`nodeName` / `attributes` /
`childNodes`).
- **vue-tui:** the host tree is a different representation
(`TuiContainer | TuiTextLeaf | TuiComment`), exported as **`TuiNode`** from
`@vue-tui/runtime/internal`.
- **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.
#### Removing `flexDirection` / `flexWrap` resets to the default
- **Ink:** these two props have no reset branch in `applyFlexStyles` (every _other_ flex
prop does), so an explicit `flexDirection={undefined}` leaves the previous value in
place.
- **vue-tui:** resets to the Box default (`row` / `nowrap`): the same state as if the prop
had never been set.
- **Why:** render is a function of the current props. With no value set, you get the
default, and (absent a special contract) dropping or changing a prop changes the output.
Keeping a previous render's value does not match that current-props model, and Ink resets
every other flex prop. Maintainer decision (2026-05-30): KEEP.
#### Removing `display` resets to the default (visible) #### Removing `display` resets to the default (visible)
- **Ink:** `applyDisplayStyles` (`styles.ts`) sets `DISPLAY_NONE` whenever an explicit - **Ink:** `applyDisplayStyles` (`styles.ts`) sets `DISPLAY_NONE` whenever an explicit
@@ -323,10 +256,9 @@ reactivity, lifecycle, component boundaries, current-props model, or API convent
value. value.
- **vue-tui:** a removed/undefined `display` resets to the Box default `DISPLAY_FLEX` - **vue-tui:** a removed/undefined `display` resets to the Box default `DISPLAY_FLEX`
(visible): the same state as if the prop had never been set. (visible): the same state as if the prop had never been set.
- **Why:** same reasoning as the `flexDirection`/`flexWrap` reset above: render = - **Why:** render = f(current props): no `display` set means the default (visible).
f(current props). No `display` set means the default (visible). Persisting a withdrawn Persisting a withdrawn prop, or flipping it to hidden, does not match that model. Maintainer
prop, or flipping it to hidden, does not match that model. Maintainer decision decision (2026-05-31): KEEP.
(2026-05-31): KEEP.
#### Public composable naming follows Vue conventions #### Public composable naming follows Vue conventions
@@ -427,6 +359,16 @@ different runtime behavior, ownership rule, or out-of-contract handling.
(`flexGrow` is not in this set: both only coerce null/undefined -> `0`.) If a reviewer (`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. shows any case is reachable in-type, it becomes a bug to fix, not a divergence.
### Removing `flexDirection` / `flexWrap` resets to the default
- **Ink:** neither prop has a reset branch in `applyFlexStyles` (every _other_ flex prop
does), so an explicit `flexDirection={undefined}` leaves the previous value in place.
- **vue-tui:** resets to the Box default (`row` / `nowrap`): the same state as if the prop
had never been set.
- **Why:** render is a function of the current props. With no value set you get the default,
and dropping a prop changes the output; keeping a previous render's value does not match
that, and Ink resets every other flex prop. Maintainer decision (2026-05-30): KEEP.
### Duplicate explicit-`id` `useFocus` calls dedup to one registry entry ### Duplicate explicit-`id` `useFocus` calls dedup to one registry entry
- **Ink:** `addFocusable` unconditionally appends, so two `useFocus({id: 'x'})` create - **Ink:** `addFocusable` unconditionally appends, so two `useFocus({id: 'x'})` create
@@ -454,6 +396,33 @@ different runtime behavior, ownership rule, or out-of-contract handling.
exceptions fall back because they have a meaningful standalone behavior (zero metrics / a exceptions fall back because they have a meaningful standalone behavior (zero metrics / a
working animation), so throwing would remove a useful capability. working animation), so throwing would remove a useful capability.
### Invalid input is validated at the component layer, not the paint layer
- **Principle:** vue-tui validates invalid render input (a chalk-**modifier**
`backgroundColor` like `"bold"`, an unknown `borderStyle`) at the
**component-render layer** (`box.ts` / `text.ts`), not down at the **paint layer**. A bad
value therefore throws where the **error boundary** catches it -> `ErrorOverview` -> a
clean `reject` of `waitUntilExit()`, exactly like any other component error. The app
reports the error instead of crashing.
- **Ink:** validates the same inputs **lazily at paint** (`colorize` / `render-border`, run
from the reconciler's commit hook): **outside** React's ErrorBoundary, so a bad value is
an uncaught crash, not a recoverable error.
- **Why:** the key constraint is where paint runs. vue-tui's paint runs in a Vue
**post-flush callback** (`queuePostFlushCb`, decoupled from render), so a throw there
escapes `onErrorCaptured` and wedges the scheduler. Unlike a component error, it cannot
be made recoverable. The escape itself is symmetric, not a Vue weakness: a component
error boundary (React `ErrorBoundary`; vue-tui's `onErrorCaptured` wrapper) covers
framework-managed component work, never the renderer's paint callbacks, so a paint-layer
throw is uncatchable in **both** engines. Validating in `Box` / `Text` keeps a bad value
on that boundary-driven recoverable path; Ink's paint-time check can only crash. This is
a deliberately chosen fail-safe given a constraint that is symmetric across React and Vue
(recover-vs-crash), not a Vue-model-forced difference — hence an intentional choice rather
than a model-implied one.
- **Cost:** the component-layer check is eager (no paint-time layout/squash info), so it
over-throws in a few degenerate, invalid-input-only cases Ink never reaches. Realistic
inputs match Ink; both error on bad input. Only the channel (recoverable reject vs crash)
differs. Tests: `background-color.test.tsx`, plus the `borderStyle` validation tests.
## Non-Behavioral Notes ## Non-Behavioral Notes
These notes are not divergence entries. They document Vue-facing conventions or internal These notes are not divergence entries. They document Vue-facing conventions or internal
@@ -462,6 +431,20 @@ mechanics so they are not mistaken for parity gaps.
- Vue SFCs use `<script setup>`, and component definitions use `defineComponent()`. - Vue SFCs use `<script setup>`, and component definitions use `defineComponent()`.
- `shallowRef` is the default for reactive state. Use `ref` only when deep reactivity is - `shallowRef` is the default for reactive state. Use `ref` only when deep reactivity is
intentional and documented. intentional and documented.
- The exported host-node type is **`TuiNode`** (`TuiContainer | TuiTextLeaf | TuiComment`,
from `@vue-tui/runtime/internal`), not Ink's DOM-emulation `DOMElement`
(`nodeName` / `attributes` / `childNodes`). vue-tui keeps a native host tree, so the
exported type names that tree; `measureElement` and template refs accept it. No runtime
behavior differs from Ink's DOM-emulation node.
- `null` / `false` / `undefined` / `v-if="false"` children are materialized by Vue as
comment vnodes, which vue-tui's host renderer turns into an inert `TuiComment`: no yoga
node, paints nothing, never shifts a sibling, and skipped when counting the positional
`<Transform>` index (the `child.type !== "comment"` guards in `paint.ts`,
`text-measure.ts`, and `screen-reader.ts`; `G52`). The result matches Ink, which drops
these children outright — React never renders `null` / `false` / `undefined` (verified
against Ink v7.0.4: `{false}`, `{null}`, `{undefined}` each produce no node; only a real
empty `<Box/>` occupies a flex-gap slot). `<Transform>` over an empty or all-comment slot
renders no node (`return null`), matching Ink's `children == null` guard.
- Commit timing is deliberately Ink-aligned: leading+trailing throttle at - Commit timing is deliberately Ink-aligned: leading+trailing throttle at
`ceil(1000/maxFps)` ms (34ms at the default `maxFps=30`, matching Ink's `ceil(1000/maxFps)` ms (34ms at the default `maxFps=30`, matching Ink's
`renderThrottleMs`), synchronous resize. This remains true even though re-renders come `renderThrottleMs`), synchronous resize. This remains true even though re-renders come
@@ -132,8 +132,10 @@ test("unparsable stack frame falls back to literal backslash-t (not a real TAB)"
test("primitive (non-Error) throw renders ERROR header with no synthetic stack", async () => { test("primitive (non-Error) throw renders ERROR header with no synthetic stack", async () => {
const frame = await renderErrorFrame(PrimitiveThrower); const frame = await renderErrorFrame(PrimitiveThrower);
// Ink derives the message from the value (String(value)) and renders just the // vue-tui renders String(value) as the message for a primitive throw, so the header
// header for a primitive throw. // shows the thrown text. (Ink renders {error.message}, blank for a primitive that has no
// .message — see .agents/docs/ink-divergences.md, section "Non-Error thrown values keep
// their message in the error overview".)
expect(frame).toContain(" ERROR primitive thrown"); expect(frame).toContain(" ERROR primitive thrown");
// A primitive has no .stack, so Ink renders no origin/excerpt/stack block. // A primitive has no .stack, so Ink renders no origin/excerpt/stack block.