From 224afeb3a228047b8e32f6bf2d6bbd81dcce819a Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Mon, 1 Jun 2026 00:29:21 +0800 Subject: [PATCH] fix(runtime): with no children renders no node, matching Ink (#105) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ink's returns null (no node) when children are undefined/null, and that guard runs BEFORE the accessibilityLabel substitution (Transform.tsx:28-30). vue always created a "transform" host node, so: - an empty in a flex `gap` row consumed a gap slot Ink never adds (P13); and - a childless emitted the label even though Ink's null guard wins over it (P19). Add the null-children guard at the top of the render fn. Vue materializes a bare null/false/undefined/v-if=false child as a single Comment vnode and cannot tell them apart, so the predicate treats the whole group as "no children" (slot undefined OR every vnode is a Comment) — matching Ink for the common `{null}`/`{cond ? x : null}` idioms and keeping consistent with vue-tui's documented comment-anchor model (every other component already omits a false/v-if child). An empty-string ({''}, a Text vnode) or JSX empty array ({[]}, a Fragment) still renders, matching Ink. This deliberately diverges from Ink only for a literal {false} / {cond && x}-false child (React's false !== null → Ink renders an empty gap-slot node); documented in ink-divergences.md and locked by a test, since Vue physically cannot distinguish it from null. Co-authored-by: Claude Opus 4.8 (1M context) --- .agents/docs/ink-divergences.md | 9 +- .../integration/components/transform.test.tsx | 98 +++++++++++++++++++ packages/runtime/src/components/Transform.ts | 60 +++++++++++- 3 files changed, 164 insertions(+), 3 deletions(-) diff --git a/.agents/docs/ink-divergences.md b/.agents/docs/ink-divergences.md index b90e2d9..bf81156 100644 --- a/.agents/docs/ink-divergences.md +++ b/.agents/docs/ink-divergences.md @@ -145,7 +145,14 @@ built never reaches the terminal: - **A `v-if=false` branch (or a `null`/`false`/`undefined` child) leaves a comment anchor (`TuiComment`)** where Ink emits no node, but it is inert: no yoga node, paints nothing, never shifts a sibling's yoga index, and is skipped for the positional `` index - in all three squash paths (`G52`). Output equals omitting the element. + in all three squash paths (`G52`). Output equals omitting the element. This also governs + ``'s own children guard: a childless `` (or one whose only child is a + `null`/`false`/`v-if=false` comment anchor) renders **no node** (matching Ink for `null`, + consistent with every other component). It diverges from Ink only for a literal `{false}` / + `{cond && x}`-false child — React's `false !== null`, so Ink renders an empty node (and a gap + slot); Vue collapses `false`/`null` to the same `TuiComment` and cannot distinguish them, so + it omits the node. Keeping `` consistent with the comment-anchor model is the + principled choice. - **Commit timing is deliberately Ink-aligned** — leading+trailing throttle at `ceil(1000/maxFps)` ≈ 32 ms (Ink's `renderThrottleMs`), synchronous resize — even though re-renders are Vue's fine-grained reactivity, not a React subtree re-render. diff --git a/packages/runtime-tests/integration/components/transform.test.tsx b/packages/runtime-tests/integration/components/transform.test.tsx index 8772373..3bd3083 100644 --- a/packages/runtime-tests/integration/components/transform.test.tsx +++ b/packages/runtime-tests/integration/components/transform.test.tsx @@ -138,6 +138,104 @@ test(" with null children", async () => { expect(lastFrame()).toBe(""); }); +// P13: an EMPTY (no children) must create NO host node, so in a flex +// row with gap it consumes NO gap slot. Ink's (Transform.tsx:28-30) +// returns null when `children === undefined || children === null`, so an empty +// sibling adds neither a node nor a gap. Ink reference (v7.0.4, +// gap=2 row): `a + + b` → "a b" (a single gap), IDENTICAL to the +// no-transform control. Previously vue-tui always created a {0,0} transform host +// node, which ate a gap slot → "a b". +test("P13: empty in a gap row consumes no gap slot", async () => { + const { lastFrame } = await render( + defineComponent(() => () => ( + + a + s} /> + b + + )), + { columns: 100 }, + ); + // Ink reference: empty Transform = no node = no gap slot → one gap of 2 spaces. + expect(lastFrame({ trimLines: true })).toBe("a b"); +}); + +test("P13 control: gap row with no transform sibling is the same width", async () => { + const { lastFrame } = await render( + defineComponent(() => () => ( + + a + b + + )), + { columns: 100 }, + ); + // Pairs with the case above: an empty Transform must match this exactly. + expect(lastFrame({ trimLines: true })).toBe("a b"); +}); + +// DELIBERATE divergence (documented in ink-divergences.md — the comment-anchor model): +// a literal `{false}` / `{cond && }`-false child. In React `false !== null`, so Ink +// renders an empty ink-text node that EATS a gap slot → "a b". Vue materializes +// `false`, `null`, `undefined`, and `v-if=false` into the SAME Comment vnode and cannot +// tell them apart, so treats them all as "no children" (omit the node) — +// rendering "a b", consistent with how every other component (e.g. ) treats a +// false/v-if child. Locking vue's principled side so it can't silently change. +test("a `{cond && x}`-false child omits the node (vue comment-anchor divergence)", async () => { + const show = false; + const { lastFrame } = await render( + defineComponent(() => () => ( + + a + s}>{show && x} + b + + )), + { columns: 100 }, + ); + // vue: false child = comment anchor = no node = no gap slot. (Ink would render "a b".) + expect(lastFrame({ trimLines: true })).toBe("a b"); +}); + +test("P13 control: NON-empty in a gap row DOES take a gap slot", async () => { + const { lastFrame } = await render( + defineComponent(() => () => ( + + a + s}> + x + + b + + )), + { columns: 100 }, + ); + // A Transform WITH children is a real node → two gap slots. Proves the fix only + // drops the empty case. Ink reference: "a x b". + expect(lastFrame({ trimLines: true })).toBe("a x b"); +}); + +// P13 boundary: an empty-STRING child is NOT null — Ink's guard is exactly +// `children === undefined || children === null`, so `{''}` +// (children === '') renders a real (0-width) node and DOES take a gap slot. Ink +// reference (gap=2 row): "a b" (two gap slots). The Vue analogue: an empty +// string materializes as a TEXT vnode (not a comment), so it must NOT be treated +// as "no children". +test("P13 boundary: empty-string-child still takes a gap slot (matches Ink)", async () => { + const { lastFrame } = await render( + defineComponent(() => () => ( + + a + s}>{""} + b + + )), + { columns: 100 }, + ); + // Ink: empty STRING child (≠ null) → real node → two gap slots → "a b". + expect(lastFrame({ trimLines: true })).toBe("a b"); +}); + test("nested transforms apply inner-first: outer wraps inner result", async () => { const outer = (s: string) => `(${s})`; const inner = (s: string) => `{${s}}`; diff --git a/packages/runtime/src/components/Transform.ts b/packages/runtime/src/components/Transform.ts index 8800c7d..1375d48 100644 --- a/packages/runtime/src/components/Transform.ts +++ b/packages/runtime/src/components/Transform.ts @@ -1,4 +1,13 @@ -import { defineComponent, h, inject, type ExtractPublicPropTypes, type PropType } from "vue"; +import { + Comment, + defineComponent, + h, + inject, + isVNode, + type ExtractPublicPropTypes, + type PropType, + type VNode, +} from "vue"; import { AppContextKey } from "../context.ts"; import type { WithChildren } from "./with-children.ts"; @@ -19,6 +28,39 @@ const TransformImpl = defineComponent({ const appCtx = inject(AppContextKey, null); return () => { + const children = slots.default?.(); + + // Mirror Ink's Transform (Transform.tsx:28-30): when there are no children + // it returns null — creating NO host node — and this guard runs BEFORE the + // accessibilityLabel substitution. Two consequences we must match: + // - an empty in a flex `gap` row adds neither a node nor a gap + // slot (P13); and + // - a childless emits nothing even in + // screen-reader mode, because null wins over the label (P19). + // + // Ink's exact guard is `children === undefined || children === null`. Vue + // can't see that raw value — `slots.default?.()` materializes a bare + // `null`/`false`/falsy-`&&`/`v-if` child as a Comment vnode (the same + // representation the G52 squash logic skips), and CANNOT tell `null` from + // `false`. So the predicate treats the whole group as "no children": the slot + // is undefined OR every resolved vnode is a Comment. This matches Ink for the + // common conditional idioms (`{null}`, `{cond ? x : null}`) and follows + // vue-tui's documented comment-anchor model (a null/false/undefined child is + // inert, output equals omitting the element — see ink-divergences.md). It + // DELIBERATELY diverges from Ink only for a literal `{false}` / `{cond && x}` + // (when false): React's `false !== null`, so Ink renders an empty ink-text + // node (a gap slot); Vue, unable to distinguish it, omits it. Keeping + // Transform consistent with how every other component treats a `false`/`v-if` + // child is the principled choice — the pre-fix Transform was the inconsistent + // one (it rendered a stray node for `{null}` too). + // (Edge: `{''}` is a TEXT vnode and JSX `{[]}` a Fragment vnode — neither a + // Comment, so both still render, matching Ink. A non-JSX `() => []` empty + // array collapses to null here; invisible — an empty-node Transform paints + // nothing and only the gap-slot differs.) + if (isNoRenderableChildren(children)) { + return null; + } + const isScreenReaderEnabled = appCtx?.isScreenReaderEnabled ?? false; // When screen reader is enabled and accessibilityLabel is set, @@ -27,12 +69,26 @@ const TransformImpl = defineComponent({ return h("transform", { transform: props.transform }, props.accessibilityLabel); } - return h("transform", { transform: props.transform }, slots.default?.()); + return h("transform", { transform: props.transform }, children); }; }, }); export const Transform = TransformImpl as WithChildren; +/** + * The Vue analogue of Ink's `children === undefined || children === null` guard + * (Transform.tsx:28). True when the default slot resolves to nothing renderable: + * either it's absent, or every vnode in it is a Comment — Vue's materialization + * of a bare `null`/`false`/falsy-`&&`/`v-if` child (the same nodes G52's squash + * skips). A text-leaf (`{''}`) or Fragment (`{[]}`) vnode is renderable, so it is + * NOT treated as null — matching Ink, which renders a node for `children === ''` + * or `[]` (both `!== null`). + */ +function isNoRenderableChildren(children: VNode[] | undefined): boolean { + if (children === undefined) return true; + return children.every((child) => isVNode(child) && child.type === Comment); +} + /** Props accepted by `` — the vue-tui analogue of Ink's `TransformProps`. */ export type TransformProps = ExtractPublicPropTypes;