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
|
- **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,
|
(`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
|
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
|
- **Commit timing is deliberately Ink-aligned** — leading+trailing throttle at
|
||||||
`ceil(1000/maxFps)` ≈ 32 ms (Ink's `renderThrottleMs`), synchronous resize — even though
|
`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.
|
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("");
|
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 () => {
|
test("nested transforms apply inner-first: outer wraps inner result", async () => {
|
||||||
const outer = (s: string) => `(${s})`;
|
const outer = (s: string) => `(${s})`;
|
||||||
const inner = (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 { AppContextKey } from "../context.ts";
|
||||||
import type { WithChildren } from "./with-children.ts";
|
import type { WithChildren } from "./with-children.ts";
|
||||||
|
|
||||||
@@ -19,6 +28,39 @@ const TransformImpl = defineComponent({
|
|||||||
const appCtx = inject(AppContextKey, null);
|
const appCtx = inject(AppContextKey, null);
|
||||||
|
|
||||||
return () => {
|
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;
|
const isScreenReaderEnabled = appCtx?.isScreenReaderEnabled ?? false;
|
||||||
|
|
||||||
// When screen reader is enabled and accessibilityLabel is set,
|
// 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 }, 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>;
|
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`. */
|
/** Props accepted by `<Transform>` — the vue-tui analogue of Ink's `TransformProps`. */
|
||||||
export type TransformProps = ExtractPublicPropTypes<typeof transformProps>;
|
export type TransformProps = ExtractPublicPropTypes<typeof transformProps>;
|
||||||
|
|||||||
Reference in New Issue
Block a user