fix(runtime): unmount written <Static> items to match Ink (G01) (#34)

* fix(runtime): unmount written <Static> items to match Ink (G01)

Ink's <Static> renders `items.slice(index)` and advances `index` to
`items.length` in a post-commit `useLayoutEffect`, so once an item has been
painted it is removed from the tree and its component unmounts. vue-tui kept
every Static item mounted forever: the component always mapped the full
`props.items`, and write-once was enforced only at flush time via a positional
`writtenCount` slice — the item components never tore down.

Now the <Static> component owns a `cursor` (Ink's `index`) and renders only
`items.slice(cursor)`. The renderer advances the cursor AFTER a commit has
painted the fresh items, via an `onWritten` callback registered on the host
static node — the vue-tui analogue of Ink's post-commit layout effect. This
ordering guarantees items are written before they are sliced out and unmounted,
so no item is ever lost or re-painted.

Write-once bookkeeping moved from a positional `writtenCount` to a
`writtenNodes` Set keyed by host-node identity. A single logical item expands to
several host nodes (the <Text>/<Box> plus empty text-leaf fragment anchors Vue
inserts), so a positional count mis-sliced once the cursor advanced; identity
tracking is anchor-agnostic. The shared `paintStaticNode` helper paints children
not yet in the set, records them, prunes unmounted entries, then fires
`onWritten`; render.ts, render-to-string.ts and flushStatic all use it.

Make the cursor mirror Ink fully so it can DECREASE, not just increase.
`onWritten` now SETS the cursor to items.length (was max-with-current), and a
length watch lowers it on shrink — needed because a shrink that leaves the
already-sliced children empty produces no host mutation, hence no commit/
onWritten to re-sync. Without this, [A,B] (cursor→2) → [A] → [A,C] sliced(2)=[]
and silently dropped C. paintStaticNode now always prunes and calls onWritten
(even on empty commits), painting only when there are fresh children.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(parity): ledger — G01 pr-open, reconcile G12 merged

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Yunfei He
2026-05-30 01:01:08 +08:00
committed by GitHub
parent 144db33d0b
commit eaf333a5ea
8 changed files with 239 additions and 40 deletions
+18 -18
View File
@@ -29,24 +29,24 @@ Non-obvious calls made while fixing gaps, recorded for review in the final repor
`status` ∈ `todo · in-progress · pr-open · merged · blocked · refuted`. Priority: correctness/behavior first, omissions next.
| id | area | summary | priority | status | branch | PR |
| --- | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | -------- | ------- | -------------------------- | --- |
| G01 | static-newline-spacer | Static keeps every already-written item permanently mounted instead of unmounting it | P1 | todo | — | — |
| G02 | app-exit-instances-animation-sr | useAnimation does not coalesce ticks within the render-throttle window — delta does not 'account for throttled renders' | P1 | todo | — | — |
| G03 | render-lifecycle-reconciler | Live screen-reader render path is missing; commit() always paints the visual grid | P1 | todo | — | — |
| G04 | box-layout-border | Border edges incorrectly inherit the Box backgroundColor | P2 | merged | `fix/parity-border-bg` | #30 |
| G05 | box-layout-border | Borders skipped when content area is 1 cell tall or wide (w<2 / h<2 guard) | P2 | todo | — | — |
| G06 | text-wrap-transform | Nested <Transform>/<Text> transform fn receives hardcoded index 0 instead of childNode index | P2 | refuted | — | — |
| G07 | input-keypress-kitty-paste | Kitty-protocol Ctrl+C triggers app exit in vue-tui but only suppresses the handler in Ink | P2 | todo | — | — |
| G08 | focus | useFocus does not react to changes in the id prop | P2 | merged | `fix/parity-usefocus-id` | #31 |
| G09 | stdout-stderr-stdin-size-cursor | External stdout/stderr writes are not wrapped in synchronized-update (BSU/ESU) markers | P2 | todo | — | — |
| G10 | stdout-stderr-stdin-size-cursor | setRawMode silently no-ops in unsupported environments instead of throwing a descriptive error | P2 | todo | — | — |
| G11 | render-lifecycle-reconciler | Resize handler does not clear+reset on terminal-width decrease | P2 | todo | — | — |
| G12 | render-lifecycle-reconciler | Renderer frame width/rows lack terminal-size fallback (only ?? defaults) | P2 | pr-open | `fix/parity-renderer-size` | #33 |
| G13 | box-layout-border | Custom border style objects (BoxStyle) not supported | P3 | todo | — | — |
| G14 | app-exit-instances-animation-sr | No per-stdout instance reuse/guard — two concurrent renderers can compete for the same stdout | P3 | todo | — | — |
| G15 | box-layout-border | Vertical border sides not shifted up when borderTop=false (Ink offsetY) — left/right rails mispositioned | P2 | todo | — | — |
| G16 | box-layout-border | Per-edge borderDimColor=false cannot override general borderDimColor (`\|\| dimAll` vs Ink's `??`) | P3 | todo | — | — |
| id | area | summary | priority | status | branch | PR |
| --- | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | -------- | ------- | --------------------------- | --- |
| G01 | static-newline-spacer | Static keeps every already-written item permanently mounted instead of unmounting it | P1 | pr-open | `fix/parity-static-unmount` | #34 |
| G02 | app-exit-instances-animation-sr | useAnimation does not coalesce ticks within the render-throttle window — delta does not 'account for throttled renders' | P1 | todo | — | — |
| G03 | render-lifecycle-reconciler | Live screen-reader render path is missing; commit() always paints the visual grid | P1 | todo | — | — |
| G04 | box-layout-border | Border edges incorrectly inherit the Box backgroundColor | P2 | merged | `fix/parity-border-bg` | #30 |
| G05 | box-layout-border | Borders skipped when content area is 1 cell tall or wide (w<2 / h<2 guard) | P2 | todo | — | — |
| G06 | text-wrap-transform | Nested <Transform>/<Text> transform fn receives hardcoded index 0 instead of childNode index | P2 | refuted | — | — |
| G07 | input-keypress-kitty-paste | Kitty-protocol Ctrl+C triggers app exit in vue-tui but only suppresses the handler in Ink | P2 | todo | — | — |
| G08 | focus | useFocus does not react to changes in the id prop | P2 | merged | `fix/parity-usefocus-id` | #31 |
| G09 | stdout-stderr-stdin-size-cursor | External stdout/stderr writes are not wrapped in synchronized-update (BSU/ESU) markers | P2 | todo | — | — |
| G10 | stdout-stderr-stdin-size-cursor | setRawMode silently no-ops in unsupported environments instead of throwing a descriptive error | P2 | todo | — | — |
| G11 | render-lifecycle-reconciler | Resize handler does not clear+reset on terminal-width decrease | P2 | todo | — | — |
| G12 | render-lifecycle-reconciler | Renderer frame width/rows lack terminal-size fallback (only ?? defaults) | P2 | merged | `fix/parity-renderer-size` | #33 |
| G13 | box-layout-border | Custom border style objects (BoxStyle) not supported | P3 | todo | — | — |
| G14 | app-exit-instances-animation-sr | No per-stdout instance reuse/guard — two concurrent renderers can compete for the same stdout | P3 | todo | — | — |
| G15 | box-layout-border | Vertical border sides not shifted up when borderTop=false (Ink offsetY) — left/right rails mispositioned | P2 | todo | — | — |
| G16 | box-layout-border | Per-edge borderDimColor=false cannot override general borderDimColor (`\|\| dimAll` vs Ink's `??`) | P3 | todo | — | — |
## Gap details
@@ -1,5 +1,5 @@
import { PassThrough } from "node:stream";
import { defineComponent, nextTick, shallowRef } from "vue";
import { defineComponent, nextTick, onUnmounted, shallowRef } from "vue";
import { expect, test } from "vite-plus/test";
import { render } from "@vue-tui/testing";
import { Box, Text, Static, createApp } from "@vue-tui/runtime";
@@ -361,6 +361,104 @@ test("render only new items in static output on final render", async () => {
expect(allOutput).toContain("B");
});
// Ink reference: src/components/Static.tsx — `itemsToRender = items.slice(index)`
// with `useLayoutEffect(() => setIndex(items.length))`. Once an item is written
// (committed/painted), the effect advances `index` past it, so on the next
// render `items.slice(index)` no longer includes it and its element is removed
// from the tree → its component UNMOUNTS. vue-tui must match: a written Static
// item's component must unmount (onUnmounted fires) while not-yet-written items
// stay mounted.
test("written Static items unmount their components (Ink parity)", async () => {
const unmounted: string[] = [];
const Item = defineComponent({
name: "StaticItem",
props: { label: { type: String, required: true } },
setup(props) {
onUnmounted(() => {
unmounted.push(props.label);
});
return () => <Text key={props.label}>{props.label}</Text>;
},
});
const items = shallowRef<string[]>(["A"]);
const App = defineComponent(() => () => (
<Box>
<Static items={items.value}>
{{
default: ({ item }: { item: string }) => <Item key={item} label={item} />,
}}
</Static>
<Text>[live]</Text>
</Box>
));
const { unmount } = await render(App);
// After the first render, item "A" has been written. Once the write settles,
// its component must unmount (mirroring Ink advancing the cursor past it).
await nextTick();
await new Promise((r) => setTimeout(r, 50));
await nextTick();
expect(unmounted).toContain("A");
// Add "B". "A" was already unmounted; only "B" is freshly mounted+written.
items.value = ["A", "B"];
await nextTick();
await new Promise((r) => setTimeout(r, 50));
await nextTick();
expect(unmounted).toContain("B");
unmount();
});
// Ink reference: Static resets `index` to `items.length` on every length change
// (`useLayoutEffect(() => setIndex(items.length), [items.length])`), so the cursor
// can DECREASE. vue-tui must mirror this. A monotonic cursor (only-increase) drops
// items after a shrink-then-grow: [A,B] writes (cursor→2); shrink to [A] (Ink resets
// cursor→1); grow to [A,C] renders slice(1)=[C] and writes C. With a monotonic
// cursor, slice(2)=[] and C is silently never painted.
test("Static resets cursor on shrink so later items still paint (Ink parity)", async () => {
const items = shallowRef<string[]>(["A", "B"]);
const App = defineComponent(() => () => (
<Box>
<Static items={items.value}>
{{
default: ({ item }: { item: string }) => <Text key={item}>{item}</Text>,
}}
</Static>
<Text>[live]</Text>
</Box>
));
const { frames } = await render(App);
// Let A and B write (cursor advances to 2).
await nextTick();
await new Promise((r) => setTimeout(r, 50));
await nextTick();
expect(frames.join("")).toContain("A");
expect(frames.join("")).toContain("B");
// Shrink to [A]. Ink resets the cursor to items.length (1); no re-paint of A.
items.value = ["A"];
await nextTick();
await new Promise((r) => setTimeout(r, 50));
await nextTick();
// Grow to [A, C]. With Ink-parity cursor reset, slice(1)=[C] paints C.
// With the monotonic bug, slice(2)=[] and C is dropped forever.
items.value = ["A", "C"];
await nextTick();
await new Promise((r) => setTimeout(r, 50));
await nextTick();
expect(frames.join("")).toContain("C");
});
test("Static items do not add blank lines to the dynamic frame", async () => {
const items = shallowRef<string[]>([]);
+42 -3
View File
@@ -1,4 +1,4 @@
import { defineComponent, h, type PropType } from "vue";
import { defineComponent, h, shallowRef, watch, type PropType } from "vue";
import type { WithChildren } from "./with-children.ts";
const StaticImpl = defineComponent({
@@ -13,12 +13,51 @@ const StaticImpl = defineComponent({
flexDirection: "column",
};
// Mirrors Ink's `const [index, setIndex] = useState(0)`. Only items at or
// after `cursor` are rendered; once written, the renderer advances the
// cursor (via the onWritten callback below) so written items unmount.
// shallowRef is sufficient — we only ever reassign the number.
const cursor = shallowRef(0);
// Invoked by the renderer AFTER a commit has painted the freshly-written
// items. Together with the watch below this is the vue-tui analogue of Ink's
// post-commit `useLayoutEffect(() => setIndex(items.length), [items.length])`.
//
// This callback handles the GROW / steady-state direction: advancing the
// cursor only AFTER paint guarantees freshly-appended items are written
// before they are sliced out and unmounted. It must run post-paint, never
// during render, which is why it can't be a plain watcher. We SET the cursor
// to items.length (not max-with-current); assigning an equal number is a
// reactivity no-op (Vue triggers on Object.is inequality), so the common
// resync-to-same-length case can't loop.
const onWritten = () => {
cursor.value = (props.items as unknown[]).length;
};
// Handles the SHRINK direction, mirroring Ink's effect firing on every
// [items.length] change — including decreases. When items shrink, the
// already-rendered Static children may already be empty (sliced out), so no
// host mutation occurs and no commit/onWritten fires; the cursor would stay
// stranded above the new length and silently drop any later-appended items
// (e.g. [A,B] cursor→2, shrink to [A], grow to [A,C] → slice(2)=[] drops C).
// Lowering the cursor on shrink is safe without waiting for a paint: shrinking
// never needs to write anything, it only re-syncs the slice window down.
watch(
() => (props.items as unknown[]).length,
(len) => {
if (len < cursor.value) cursor.value = len;
},
);
return () => {
const merged = { ...defaultStyle, ...props.style };
const items = props.items as unknown[];
const start = cursor.value;
const itemsToRender = items.slice(start);
return h(
"static",
merged,
(props.items as unknown[]).map((item, index) => slots.default?.({ item, index })),
{ ...merged, internal_onWritten: onWritten },
itemsToRender.map((item, i) => slots.default?.({ item, index: start + i })),
);
};
},
+7
View File
@@ -253,6 +253,13 @@ export function buildNodeOps(options: TtyRendererOptions): RendererOptions<TuiNo
onCommit();
return;
}
if (el.type === "static" && key === "internal_onWritten") {
// Callback the renderer invokes post-commit to advance the <Static>
// component's cursor so written items unmount. Not styling/layout.
el.onWritten = typeof next === "function" ? (next as () => void) : undefined;
onCommit();
return;
}
if (el.type === "box" || el.type === "text" || el.type === "static" || el.type === "root") {
if (isYogaProp(key)) {
applyYogaProp(el, key, next);
+20 -2
View File
@@ -83,7 +83,25 @@ export interface TuiStatic extends NodeBase {
children: TuiNode[];
yoga: YogaNodeRef;
props: BoxProps;
writtenCount: number;
/**
* Host child nodes already written to the static channel. Static items are
* write-once: each commit only paints the children NOT in this set. We track
* by node identity rather than a count because a single logical item expands
* to several host nodes (the <Text>/<Box> plus empty text-leaf fragment
* anchors Vue inserts), so a positional `writtenCount` would mis-slice. Once a
* child is painted it is recorded here, then the <Static> component advances
* its cursor and unmounts it (mirroring Ink's `setIndex(items.length)`).
*/
writtenNodes: Set<TuiNode>;
/**
* Callback registered by the <Static> component, invoked by the renderer AFTER
* a commit has painted freshly-written items. It advances the component's
* reactive cursor (Ink's `index`) so the just-written items are sliced out and
* unmount on the next render — the vue-tui analogue of Ink's post-commit
* `useLayoutEffect(() => setIndex(items.length))`. Advancing AFTER paint (never
* during render) guarantees items are written before they are dropped.
*/
onWritten?: () => void;
}
export interface TuiTransform extends NodeBase {
@@ -171,7 +189,7 @@ export function createStatic(): TuiStatic {
children: [],
yoga: UNATTACHED_YOGA,
props: {},
writtenCount: 0,
writtenNodes: new Set(),
};
}
+47 -4
View File
@@ -10,12 +10,55 @@ export function findStatics(root: TuiNode, out: TuiStatic[] = []): TuiStatic[] {
return out;
}
/**
* Paint the not-yet-written children of a single <Static> node and record them
* as written. Returns the painted frame (without trailing "\n"), or "" when
* there is nothing fresh to write.
*
* Static items are write-once. `stat.children` only ever holds the currently-
* mounted (un-written) items because the <Static> component slices written ones
* out — but between a write and the component's cursor advance, the just-written
* children are still mounted, so we must skip any child already in `writtenNodes`
* (identity-tracked, since one item = several host nodes incl. fragment anchors).
* After painting the fresh children we call `onWritten` so the component advances
* its cursor and unmounts them (the post-commit step mirroring Ink's
* `useLayoutEffect(setIndex)`).
*/
export function paintStaticNode(stat: TuiStatic, columns: number): string {
const fresh = stat.children.filter((child) => !stat.writtenNodes.has(child));
// Paint (and record as written) only when there is something fresh — but the
// prune and onWritten steps below run on EVERY commit, including the empty
// commit that follows a cursor advance (children sliced to []). That empty
// commit is exactly when we must (a) prune stale unmounted nodes and (b)
// re-sync the cursor to items.length: on a shrink ([A,B]→[A]), nothing fresh
// paints, yet Ink's `useLayoutEffect(setIndex(items.length))` still fires and
// lowers the cursor so subsequent grows ([A,C]) render and write the new item.
let frame = "";
if (fresh.length > 0) {
frame = paintIsolated(fresh, columns, stat);
for (const child of fresh) stat.writtenNodes.add(child);
}
// Prune entries that are no longer mounted so the set can't grow unbounded
// over a long-running app (written children get unmounted on the next render).
if (stat.writtenNodes.size > stat.children.length) {
const live = new Set(stat.children);
for (const node of stat.writtenNodes) {
if (!live.has(node)) stat.writtenNodes.delete(node);
}
}
// Defer the cursor sync to AFTER this commit so the just-painted items are
// still mounted while they are written; the callback re-renders and drops
// them. Always called (even on empty commits) so the cursor tracks
// items.length every commit — mirroring Ink's effect on [items.length].
// Setting the cursor to an unchanged value is a reactivity no-op, so this
// cannot loop.
stat.onWritten?.();
return frame;
}
export function flushStatic(root: TuiNode, stream: NodeJS.WriteStream): void {
for (const stat of findStatics(root)) {
const fresh = stat.children.slice(stat.writtenCount);
if (fresh.length === 0) continue;
const frame = paintIsolated(fresh, stream.columns ?? 80, stat);
const frame = paintStaticNode(stat, stream.columns ?? 80);
if (frame.length > 0) stream.write(frame + "\n");
stat.writtenCount = stat.children.length;
}
}
+3 -6
View File
@@ -6,9 +6,9 @@ import Yoga from "yoga-layout";
import { createRoot, type TuiNode } from "./host/nodes.ts";
import { attachYoga, detachYoga } from "./host/yoga.ts";
import { buildNodeOps } from "./host/node-ops.ts";
import { paint, paintIsolated } from "./paint/paint.ts";
import { paint } from "./paint/paint.ts";
import { renderScreenReaderOutput } from "./paint/screen-reader.ts";
import { findStatics } from "./paint/static-channel.ts";
import { findStatics, paintStaticNode } from "./paint/static-channel.ts";
import {
AppContextKey,
FocusContextKey,
@@ -78,13 +78,10 @@ export function renderToString(component: Component, options?: RenderToStringOpt
root.yoga.calculateLayout(columns, undefined, Yoga.DIRECTION_LTR);
// Flush static output from intermediate renders
for (const stat of findStatics(root)) {
const fresh = stat.children.slice(stat.writtenCount);
if (fresh.length === 0) continue;
const staticFrame = paintIsolated(fresh, columns, stat);
const staticFrame = paintStaticNode(stat, columns);
if (staticFrame && staticFrame !== "\n") {
capturedStaticOutput += staticFrame + "\n";
}
stat.writtenCount = stat.children.length;
}
},
}),
+3 -6
View File
@@ -21,8 +21,8 @@ import { attachYoga, detachYoga } from "./host/yoga.ts";
import { buildNodeOps } from "./host/node-ops.ts";
import { createCommitScheduler } from "./scheduler.ts";
import { createAnimationScheduler } from "./animation-scheduler.ts";
import { paint, paintIsolated } from "./paint/paint.ts";
import { findStatics } from "./paint/static-channel.ts";
import { paint } from "./paint/paint.ts";
import { findStatics, paintStaticNode } from "./paint/static-channel.ts";
import { createFrameWriter } from "./io/frame-writer.ts";
import { bsu, esu, shouldSynchronize } from "./io/write-synchronized.ts";
import {
@@ -550,13 +550,10 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
const w = resolveSize(stdout).columns;
let staticOutput = "";
for (const stat of findStatics(tuiRoot)) {
const fresh = stat.children.slice(stat.writtenCount);
if (fresh.length === 0) continue;
const staticFrame = paintIsolated(fresh, w, stat);
const staticFrame = paintStaticNode(stat, w);
if (staticFrame.length > 0) {
staticOutput += staticFrame + "\n";
}
stat.writtenCount = stat.children.length;
}
const hasStaticOutput = staticOutput !== "" && staticOutput !== "\n";
if (hasStaticOutput) {