fix(runtime): <Transform> with no children renders no node, matching Ink (#105)

Ink's <Transform> 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 <Transform> in a flex `gap` row consumed a gap slot Ink never adds (P13); and
- a childless <Transform accessibilityLabel> 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 <Transform> 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) <noreply@anthropic.com>
This commit is contained in:
Yunfei He
2026-06-01 00:29:21 +08:00
committed by GitHub
parent 6078cb7a80
commit 224afeb3a2
3 changed files with 164 additions and 3 deletions
+8 -1
View File
@@ -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 `<Transform>` 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
`<Transform>`'s own children guard: a childless `<Transform>` (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 `<Transform>` 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.
@@ -138,6 +138,104 @@ test("<Transform> with null children", async () => {
expect(lastFrame()).toBe("");
});
// P13: an EMPTY <Transform> (no children) must create NO host node, so in a flex
// row with gap it consumes NO gap slot. Ink's <Transform> (Transform.tsx:28-30)
// returns null when `children === undefined || children === null`, so an empty
// <Transform> sibling adds neither a node nor a gap. Ink reference (v7.0.4,
// gap=2 row): `a + <Transform/> + 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 <Transform> in a gap row consumes no gap slot", async () => {
const { lastFrame } = await render(
defineComponent(() => () => (
<Box flexDirection="row" gap={2}>
<Text>a</Text>
<Transform transform={(s: string) => s} />
<Text>b</Text>
</Box>
)),
{ 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(() => () => (
<Box flexDirection="row" gap={2}>
<Text>a</Text>
<Text>b</Text>
</Box>
)),
{ 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 && <x/>}`-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 <Transform> treats them all as "no children" (omit the node) —
// rendering "a b", consistent with how every other component (e.g. <Box>) treats a
// false/v-if child. Locking vue's principled side so it can't silently change.
test("a `{cond && x}`-false <Transform> child omits the node (vue comment-anchor divergence)", async () => {
const show = false;
const { lastFrame } = await render(
defineComponent(() => () => (
<Box flexDirection="row" gap={2}>
<Text>a</Text>
<Transform transform={(s: string) => s}>{show && <Text>x</Text>}</Transform>
<Text>b</Text>
</Box>
)),
{ 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 <Transform> in a gap row DOES take a gap slot", async () => {
const { lastFrame } = await render(
defineComponent(() => () => (
<Box flexDirection="row" gap={2}>
<Text>a</Text>
<Transform transform={(s: string) => s}>
<Text>x</Text>
</Transform>
<Text>b</Text>
</Box>
)),
{ 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 `<Transform>{''}</Transform>`
// (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 <Transform> still takes a gap slot (matches Ink)", async () => {
const { lastFrame } = await render(
defineComponent(() => () => (
<Box flexDirection="row" gap={2}>
<Text>a</Text>
<Transform transform={(s: string) => s}>{""}</Transform>
<Text>b</Text>
</Box>
)),
{ 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}}`;
+58 -2
View File
@@ -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 <Transform> in a flex `gap` row adds neither a node nor a gap
// slot (P13); and
// - a childless <Transform accessibilityLabel> 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<typeof TransformImpl>;
/**
* 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 `<Transform>` — the vue-tui analogue of Ink's `TransformProps`. */
export type TransformProps = ExtractPublicPropTypes<typeof transformProps>;