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:
@@ -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}}`;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user