From eaf333a5ea57dc45f322ef08bb4427749c7db667 Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Sat, 30 May 2026 01:01:08 +0800 Subject: [PATCH] fix(runtime): unmount written items to match Ink (G01) (#34) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(runtime): unmount written items to match Ink (G01) Ink's 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 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 / 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 * chore(parity): ledger — G01 pr-open, reconcile G12 merged Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .agents/docs/parity-ledger.md | 36 +++---- .../integration/components/static.test.tsx | 100 +++++++++++++++++- packages/runtime/src/components/Static.ts | 45 +++++++- packages/runtime/src/host/node-ops.ts | 7 ++ packages/runtime/src/host/nodes.ts | 22 +++- packages/runtime/src/paint/static-channel.ts | 51 ++++++++- packages/runtime/src/render-to-string.ts | 9 +- packages/runtime/src/render.ts | 9 +- 8 files changed, 239 insertions(+), 40 deletions(-) diff --git a/.agents/docs/parity-ledger.md b/.agents/docs/parity-ledger.md index 3f6bbcb..e8bf32f 100644 --- a/.agents/docs/parity-ledger.md +++ b/.agents/docs/parity-ledger.md @@ -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 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 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 diff --git a/packages/runtime-tests/integration/components/static.test.tsx b/packages/runtime-tests/integration/components/static.test.tsx index b1d3e8e..4666093 100644 --- a/packages/runtime-tests/integration/components/static.test.tsx +++ b/packages/runtime-tests/integration/components/static.test.tsx @@ -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 () => {props.label}; + }, + }); + + const items = shallowRef(["A"]); + + const App = defineComponent(() => () => ( + + + {{ + default: ({ item }: { item: string }) => , + }} + + [live] + + )); + + 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(["A", "B"]); + + const App = defineComponent(() => () => ( + + + {{ + default: ({ item }: { item: string }) => {item}, + }} + + [live] + + )); + + 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([]); diff --git a/packages/runtime/src/components/Static.ts b/packages/runtime/src/components/Static.ts index 71d17ba..d3474de 100644 --- a/packages/runtime/src/components/Static.ts +++ b/packages/runtime/src/components/Static.ts @@ -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 })), ); }; }, diff --git a/packages/runtime/src/host/node-ops.ts b/packages/runtime/src/host/node-ops.ts index cfc4f7d..9d11699 100644 --- a/packages/runtime/src/host/node-ops.ts +++ b/packages/runtime/src/host/node-ops.ts @@ -253,6 +253,13 @@ export function buildNodeOps(options: TtyRendererOptions): RendererOptions + // 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); diff --git a/packages/runtime/src/host/nodes.ts b/packages/runtime/src/host/nodes.ts index a38f19b..fd0d39d 100644 --- a/packages/runtime/src/host/nodes.ts +++ b/packages/runtime/src/host/nodes.ts @@ -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 / 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 component advances + * its cursor and unmounts it (mirroring Ink's `setIndex(items.length)`). + */ + writtenNodes: Set; + /** + * Callback registered by the 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(), }; } diff --git a/packages/runtime/src/paint/static-channel.ts b/packages/runtime/src/paint/static-channel.ts index 89fb0aa..1d48821 100644 --- a/packages/runtime/src/paint/static-channel.ts +++ b/packages/runtime/src/paint/static-channel.ts @@ -10,12 +10,55 @@ export function findStatics(root: TuiNode, out: TuiStatic[] = []): TuiStatic[] { return out; } +/** + * Paint the not-yet-written children of a single 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 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; } } diff --git a/packages/runtime/src/render-to-string.ts b/packages/runtime/src/render-to-string.ts index 1fb0702..9283759 100644 --- a/packages/runtime/src/render-to-string.ts +++ b/packages/runtime/src/render-to-string.ts @@ -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; } }, }), diff --git a/packages/runtime/src/render.ts b/packages/runtime/src/render.ts index 4993793..93c54e0 100644 --- a/packages/runtime/src/render.ts +++ b/packages/runtime/src/render.ts @@ -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) {