fix(runtime): removing display resets to the default (visible), not persist (Ink divergence) (#89)
vue-tui left `display` out of RESETTABLE_PROPS, so a removed/undefined `display` persisted its prior value (a removed display="none" stayed hidden). Adds `display` to RESETTABLE_PROPS -- the setter already maps undefined -> DISPLAY_FLEX -- so a withdrawn `display` returns to the Box default (visible), per render = f(current props), like flexDirection/flexWrap (G19). Deliberate, documented divergence from Ink (which hides on a present-undefined `display` via DISPLAY_NONE, and persists on omitted) -- recorded in ink-divergences.md. The reset is consistent across the visual and screen-reader paths (both read yoga's display state). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -89,6 +89,17 @@ deliberate. Divergences fall into a few kinds:
|
|||||||
Keeping a previous render's value, as Ink does for these two props, is the anomaly — and an
|
Keeping a previous render's value, as Ink does for these two props, is the anomaly — and an
|
||||||
inconsistent one, since every other flex prop resets. Maintainer decision (2026-05-30): KEEP.
|
inconsistent one, since every other flex prop resets. Maintainer decision (2026-05-30): KEEP.
|
||||||
|
|
||||||
|
### Removing `display` resets to the default (visible)
|
||||||
|
|
||||||
|
- **Ink:** `applyDisplayStyles` (`styles.ts`) sets `DISPLAY_NONE` whenever an explicit
|
||||||
|
`display` is present and not `'flex'` — so a present-but-undefined `display={undefined}`
|
||||||
|
**hides** the box, and an omitted `display` **persists** the prior value.
|
||||||
|
- **vue-tui:** a removed/undefined `display` resets to the Box default `DISPLAY_FLEX`
|
||||||
|
(visible) — the same state as if the prop had never been set.
|
||||||
|
- **Why:** same reasoning as the `flexDirection`/`flexWrap` reset above — render =
|
||||||
|
f(current props): no `display` set → the default (visible). Persisting a withdrawn prop,
|
||||||
|
or flipping it to hidden, is the anomaly. Maintainer decision (2026-05-31): KEEP.
|
||||||
|
|
||||||
## Not applicable in Vue
|
## Not applicable in Vue
|
||||||
|
|
||||||
### React concurrent mode
|
### React concurrent mode
|
||||||
|
|||||||
@@ -263,6 +263,70 @@ test("marginX composes with marginLeft (specific edge wins) (Blocker 1)", async
|
|||||||
expect(lastFrame({ trimLines: true })).toBe(" X Y");
|
expect(lastFrame({ trimLines: true })).toBe(" X Y");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Removing `display` resets to the DEFAULT (visible / DISPLAY_FLEX), not persist
|
||||||
|
// and not hide. This is a DELIBERATE divergence from Ink documented in
|
||||||
|
// .agents/docs/ink-divergences.md ("Removing `display` resets to the default"):
|
||||||
|
// Ink's applyDisplayStyles hides on a present-but-undefined `display` (and persists
|
||||||
|
// on omitted); vue-tui treats a removed prop as "back to the default" per the
|
||||||
|
// declarative contract (render = f(current props)) — same reasoning as the
|
||||||
|
// flexDirection/flexWrap reset (G19).
|
||||||
|
|
||||||
|
test("reset display=none to visible default on removal (display divergence)", async () => {
|
||||||
|
// display="none" hides 'X'; removing the prop must reset to the default (visible),
|
||||||
|
// NOT keep the stale DISPLAY_NONE (the bug) and NOT hide (Ink's behavior).
|
||||||
|
const hidden = shallowRef(true);
|
||||||
|
|
||||||
|
const Dynamic = defineComponent(() => () => (
|
||||||
|
<Box {...(hidden.value ? { display: "none" } : {})}>
|
||||||
|
<Text>X</Text>
|
||||||
|
</Box>
|
||||||
|
));
|
||||||
|
|
||||||
|
const { lastFrame } = await render(Dynamic, { columns: 100 });
|
||||||
|
// While display="none" is set, the box and its text are hidden.
|
||||||
|
expect(lastFrame({ trimLines: true })).toBe("");
|
||||||
|
|
||||||
|
hidden.value = false;
|
||||||
|
await nextTick();
|
||||||
|
// After removal, display resets to the default (DISPLAY_FLEX) → 'X' is visible.
|
||||||
|
expect(lastFrame({ trimLines: true })).toBe("X");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("display=flex removed stays visible (default unchanged) (display divergence)", async () => {
|
||||||
|
// An explicit display="flex" is already the default; removing it must leave the
|
||||||
|
// box visible (default), confirming removal lands on the default both ways.
|
||||||
|
const explicit = shallowRef(true);
|
||||||
|
|
||||||
|
const Dynamic = defineComponent(() => () => (
|
||||||
|
<Box {...(explicit.value ? { display: "flex" } : {})}>
|
||||||
|
<Text>X</Text>
|
||||||
|
</Box>
|
||||||
|
));
|
||||||
|
|
||||||
|
const { lastFrame } = await render(Dynamic, { columns: 100 });
|
||||||
|
expect(lastFrame({ trimLines: true })).toBe("X");
|
||||||
|
|
||||||
|
explicit.value = false;
|
||||||
|
await nextTick();
|
||||||
|
expect(lastFrame({ trimLines: true })).toBe("X");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("explicit display=none still hides while set (display divergence control)", async () => {
|
||||||
|
// Control: an explicitly-set display="none" must still hide — the reset only
|
||||||
|
// fires on REMOVAL, never while the prop holds the value "none".
|
||||||
|
const Dynamic = defineComponent(() => () => (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<Box display="none">
|
||||||
|
<Text>hidden</Text>
|
||||||
|
</Box>
|
||||||
|
<Text>shown</Text>
|
||||||
|
</Box>
|
||||||
|
));
|
||||||
|
|
||||||
|
const { lastFrame } = await render(Dynamic, { columns: 100 });
|
||||||
|
expect(lastFrame({ trimLines: true })).toBe("shown");
|
||||||
|
});
|
||||||
|
|
||||||
test("reset position to relative on removal (G19)", async () => {
|
test("reset position to relative on removal (G19)", async () => {
|
||||||
// position=absolute with offsets removes the box from flow and moves it visually;
|
// position=absolute with offsets removes the box from flow and moves it visually;
|
||||||
// removing 'position' should restore relative positioning (back in flow at top).
|
// removing 'position' should restore relative positioning (back in flow at top).
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ import { createBox } from "../../runtime/src/host/nodes.ts";
|
|||||||
const EDGE_LEFT = 0;
|
const EDGE_LEFT = 0;
|
||||||
const EDGE_TOP = 1;
|
const EDGE_TOP = 1;
|
||||||
const DIRECTION_LTR = 1;
|
const DIRECTION_LTR = 1;
|
||||||
|
// Display enum (YGEnums Display): Flex=0 (default/visible), None=1 (hidden).
|
||||||
|
const DISPLAY_FLEX = 0;
|
||||||
|
const DISPLAY_NONE = 1;
|
||||||
|
|
||||||
// Blocker 2: Vue's HOST renderer passes next=null (not undefined) when a key
|
// Blocker 2: Vue's HOST renderer passes next=null (not undefined) when a key
|
||||||
// disappears from a spread props object (e.g. Static spreads `style` into host
|
// disappears from a spread props object (e.g. Static spreads `style` into host
|
||||||
@@ -62,3 +65,46 @@ test("raw null does not corrupt a yoga dimension to NaN (Blocker 2)", () => {
|
|||||||
expect(m).toBe(0);
|
expect(m).toBe(0);
|
||||||
detachYoga(box);
|
detachYoga(box);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// display: removing/undefining `display` resets to the DEFAULT (DISPLAY_FLEX =
|
||||||
|
// visible), a DELIBERATE divergence from Ink (which hides on present-undefined).
|
||||||
|
// See .agents/docs/ink-divergences.md ("Removing `display` resets to the
|
||||||
|
// default"). These pin the yoga-level reset directly via getDisplay().
|
||||||
|
|
||||||
|
test("removing display=none resets to DISPLAY_FLEX, not stale DISPLAY_NONE (display divergence)", () => {
|
||||||
|
const box = freshBox();
|
||||||
|
applyYogaProp(box, "display", "none", undefined);
|
||||||
|
expect(box.yoga.getDisplay()).toBe(DISPLAY_NONE);
|
||||||
|
|
||||||
|
// Removal: prev="none", next=undefined → reset to the default (visible).
|
||||||
|
applyYogaProp(box, "display", undefined, "none");
|
||||||
|
expect(box.yoga.getDisplay()).toBe(DISPLAY_FLEX);
|
||||||
|
|
||||||
|
detachYoga(box);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("null removal of display=none also resets to DISPLAY_FLEX (spread-props path)", () => {
|
||||||
|
const box = freshBox();
|
||||||
|
applyYogaProp(box, "display", "none", undefined);
|
||||||
|
expect(box.yoga.getDisplay()).toBe(DISPLAY_NONE);
|
||||||
|
|
||||||
|
// Vue's host renderer passes next=null when a key vanishes from a spread props
|
||||||
|
// object; the `value == null` reset path must treat it like undefined.
|
||||||
|
applyYogaProp(box, "display", null, "none");
|
||||||
|
expect(box.yoga.getDisplay()).toBe(DISPLAY_FLEX);
|
||||||
|
|
||||||
|
detachYoga(box);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("absent-on-mount display (prev=null/undefined) does not force a reset write (display divergence)", () => {
|
||||||
|
// Guard check: on first mount Vue emits patchProp(el, 'display', null/undefined,
|
||||||
|
// undefined) for an unset prop. With no prior real value the reset must NOT
|
||||||
|
// fire — the node keeps yoga's default (DISPLAY_FLEX) regardless, so this only
|
||||||
|
// confirms the absent path is inert and never lands on DISPLAY_NONE.
|
||||||
|
const box = freshBox();
|
||||||
|
applyYogaProp(box, "display", undefined, undefined);
|
||||||
|
expect(box.yoga.getDisplay()).toBe(DISPLAY_FLEX);
|
||||||
|
applyYogaProp(box, "display", null, undefined);
|
||||||
|
expect(box.yoga.getDisplay()).toBe(DISPLAY_FLEX);
|
||||||
|
detachYoga(box);
|
||||||
|
});
|
||||||
|
|||||||
@@ -402,6 +402,13 @@ const RESETTABLE_PROPS = new Set([
|
|||||||
"paddingLeft",
|
"paddingLeft",
|
||||||
"paddingRight",
|
"paddingRight",
|
||||||
"position",
|
"position",
|
||||||
|
// display: removing/undefining `display` resets to the DEFAULT (visible,
|
||||||
|
// DISPLAY_FLEX) — the setter already maps undefined → DISPLAY_FLEX. This is a
|
||||||
|
// DELIBERATE divergence from Ink (which hides on a present-undefined `display`):
|
||||||
|
// render = f(current props), so a withdrawn prop returns to the default, just
|
||||||
|
// like flexDirection/flexWrap (G19). See .agents/docs/ink-divergences.md
|
||||||
|
// ("Removing `display` resets to the default").
|
||||||
|
"display",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export function applyYogaProp(
|
export function applyYogaProp(
|
||||||
|
|||||||
Reference in New Issue
Block a user