From bf9d9d4a4a3495f9f0b27df3d627eeb354944bf2 Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Mon, 15 Jun 2026 01:57:52 +0800 Subject: [PATCH] fix(runtime): re-measure text when wrap changes at runtime (#193) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `wrap` prop changes a node's MEASURED height (the yoga measure func reads el.props.wrap to pick wrap/truncate/hard layout) but is NOT a yoga prop, so a runtime wrap-only change took the generic STYLE_PROPS branch in patchProp: it stored the new value into el.props and called onCommit() WITHOUT markTextDirty(el). Yoga kept the OLD wrap mode's cached height while paint rendered with the NEW wrap, so layout and paint disagreed -- stale blank rows on wrap->truncate, overflow / overwritten siblings on truncate->wrap. Mark the text node dirty when the changed STYLE_PROP is `wrap` on a tui-text node so yoga re-measures. `wrap` is the only STYLE_PROP that affects measured dimensions (the rest are paint-only), so it is the sole case. Verified Ink v7.0.4 has the identical latent bug -- its applyStyles ignores textWrap and never markDirty()s, so a wrap-only change goes stale there too. Recorded as a blessed divergence in ink-divergences.md; the fix matches the layout Ink produces whenever its measure func is invalidated. Tests (text-wrap-remeasure.test.tsx) reproduce both directions: RED produced Ink's stale frame ("aaaa …\n\n\nZZZZ"), GREEN the correct re-measured layout. Co-authored-by: Claude Opus 4.8 (1M context) --- .agents/docs/ink-divergences.md | 27 +++++++ .../layout/text-wrap-remeasure.test.tsx | 71 +++++++++++++++++++ packages/runtime/src/host/node-ops.ts | 15 ++++ 3 files changed, 113 insertions(+) create mode 100644 packages/runtime-tests/integration/layout/text-wrap-remeasure.test.tsx diff --git a/.agents/docs/ink-divergences.md b/.agents/docs/ink-divergences.md index 321b8d2..c529f62 100644 --- a/.agents/docs/ink-divergences.md +++ b/.agents/docs/ink-divergences.md @@ -455,6 +455,33 @@ different runtime behavior, ownership rule, or out-of-contract handling. (`throw {message:'x'}` once displayed `x` but rejected `[object Object]`). Introduced 2026-05-31; consistency fixed 2026-06-12. KEEP. [VOUCHED @hyf0] +### Re-measure text when the `wrap` prop changes at runtime + +- **Ink:** a runtime `wrap` (style `textWrap`) change goes through `commitUpdate` → + `applyStyles`, but `applyStyles` **ignores `textWrap` entirely** (styles.ts) and never + calls `yogaNode.markDirty()`. Only `setTextNodeValue` (a text-CONTENT change) dirties the + measure func. So when ONLY `wrap` toggles, yoga keeps the previously-measured height while + paint renders with the new wrap mode → layout and paint disagree. Run-verified vs v7.0.4 + (`/tmp/ink-verify`, debug-mode frame capture): a width-6 column `` with + `` over `"aaaa bbbb cccc"` and a `ZZZZ` sentinel below, toggled wrap→truncate, + yields `"aaaa …\n\n\nZZZZ"` — the truncated text paints on row 1 but yoga still reserves 3 + rows, stranding `ZZZZ` on row 4 with blank rows. Toggling text content alongside `wrap` + (which DOES `markDirty`) gives the correct `"aaaa …\nZZZZ"`, proving the cause. +- **vue-tui:** the host `patchProp` (`node-ops.ts`) calls `markTextDirty(el)` when the changed + STYLE_PROP is `wrap` on a `tui-text` node, so yoga re-measures and layout matches paint: + wrap→truncate collapses to `"aaaa …\nZZZZ"`, truncate→wrap grows to + `"aaaa\nbbbb\ncccc\nZZZZ"`. `wrap` is the only STYLE_PROP that affects measured height (the + measure func reads `text.props.wrap`); the rest (color/bold/border colors/…) are paint-only, + so this is the sole case. +- **Why:** aligning to Ink reduces bugs only where Ink is correct. Here Ink is itself + buggy — a stale cached measure that contradicts paint — so vue-tui deliberately diverges to + the obviously-correct behavior: render = f(current props), layout and paint agree. The fix + is minimal (one `markDirty`) and matches the layout Ink ALREADY produces whenever its measure + func happens to be invalidated. KEEP — proposed divergence, PENDING @hyf0 vouch (Ink is buggy + here; awaiting human bless before this is settled). Tests: + `text-wrap-remeasure.test.tsx` (both directions; RED without the fix, reproducing Ink's + stale frame). + ### Second `mount()` on a live stdout is an inert no-op - **Ink:** `render()` keeps one instance per stdout (`WeakMap`); a second diff --git a/packages/runtime-tests/integration/layout/text-wrap-remeasure.test.tsx b/packages/runtime-tests/integration/layout/text-wrap-remeasure.test.tsx new file mode 100644 index 0000000..125f767 --- /dev/null +++ b/packages/runtime-tests/integration/layout/text-wrap-remeasure.test.tsx @@ -0,0 +1,71 @@ +import { defineComponent, nextTick, shallowRef } from "vue"; +import { expect, test } from "vite-plus/test"; +import { render } from "@vue-tui/testing"; +import { Box, Text } from "@vue-tui/runtime"; + +// Changing a 's `wrap` prop at runtime changes how tall the text MEASURES +// (truncate = 1 row; wrap = 3 rows for this content/width), but `wrap` is not a +// yoga prop — it only feeds the text measure func. The measure result is cached +// by yoga, so a wrap-only change must re-mark the text dirty or yoga keeps the +// stale height while paint uses the new wrap mode → layout and paint disagree: +// stale blank rows (wrap→truncate) or stranded siblings. +// +// We anchor each frame against Ink v7.0.4 rendered standalone in *each* mode +// (the layout Ink produces when its measure func is correctly invalidated): +// wrap -> "aaaa\nbbbb\ncccc\nZZZZ" +// truncate -> "aaaa …\nZZZZ" +// (Ink itself has this latent bug on a wrap-ONLY change — see ink-divergences.md +// "Re-measure text when the `wrap` prop changes at runtime".) + +// Box width 6, column layout. "aaaa bbbb cccc" is 14 cols. +// - wrap: wraps to 3 rows ("aaaa" / "bbbb" / "cccc"), sentinel on row 4 +// - truncate: 1 row ("aaaa …"), sentinel on row 2 +const CONTENT = "aaaa bbbb cccc"; + +test("wrap -> truncate re-measures: text collapses, sentinel rises (no stale blank rows)", async () => { + const wrap = shallowRef<"wrap" | "truncate">("wrap"); + + const Dynamic = defineComponent(() => () => ( + + {CONTENT} + ZZZZ + + )); + + const { lastFrame } = await render(Dynamic, { columns: 40 }); + + // Initial wrap layout: 3 wrapped rows + sentinel. + expect(lastFrame()).toBe("aaaa\nbbbb\ncccc\nZZZZ"); + + wrap.value = "truncate"; + await nextTick(); + + // After re-measure the text is one truncated row and the sentinel rises to + // row 2. Before the fix the cached 3-row height persists, leaving stale blank + // rows and stranding the sentinel: "aaaa …\n\n\nZZZZ" (matches buggy Ink). + expect(lastFrame()).toBe("aaaa …\nZZZZ"); +}); + +test("truncate -> wrap re-measures: text grows to wrapped rows, sentinel descends", async () => { + const wrap = shallowRef<"wrap" | "truncate">("truncate"); + + const Dynamic = defineComponent(() => () => ( + + {CONTENT} + ZZZZ + + )); + + const { lastFrame } = await render(Dynamic, { columns: 40 }); + + // Initial truncate layout: 1 row + sentinel. + expect(lastFrame()).toBe("aaaa …\nZZZZ"); + + wrap.value = "wrap"; + await nextTick(); + + // After re-measure the text occupies 3 wrapped rows and the sentinel descends + // to row 4. Before the fix the cached 1-row height persists, so the wrapped + // rows overflow past the reserved space / overwrite the sentinel. + expect(lastFrame()).toBe("aaaa\nbbbb\ncccc\nZZZZ"); +}); diff --git a/packages/runtime/src/host/node-ops.ts b/packages/runtime/src/host/node-ops.ts index 0621eb8..735a6a0 100644 --- a/packages/runtime/src/host/node-ops.ts +++ b/packages/runtime/src/host/node-ops.ts @@ -480,6 +480,21 @@ export function buildNodeOps(options: TtyRendererOptions): RendererOptions }).props[key] = next; + // `wrap` is the one STYLE_PROP that changes a text node's MEASURED height + // (the measure func reads el.props.wrap to pick wrap/truncate/hard layout) + // yet is NOT a yoga prop — so it skips applyYogaProp and never invalidates + // yoga's cached measurement above. Without re-marking dirty, yoga keeps the + // OLD wrap mode's height while paint renders with the NEW wrap → layout and + // paint disagree (stale blank rows on wrap→truncate; overflow / overwritten + // siblings on truncate→wrap). markTextDirty forces a re-measure. Every other + // STYLE_PROP here (color/bold/border colors/…) is paint-only and never alters + // measured dimensions, so `wrap` is the sole case. NOTE: this also fixes a + // latent bug present in Ink v7.0.4 itself — Ink's applyStyles ignores + // textWrap and never markDirty()s, so a wrap-only change goes stale there too + // (a deliberate divergence pending a human vouch; see ink-divergences.md). + if (key === "wrap" && el.type === "tui-text") { + markTextDirty(el); + } } else if (key === "aria-role" || key === "ariaRole") { if (el.type === "tui-box") { el.internal_accessibility ??= {};