From 8c3e97ab47f9b1c4324472c9b8656ac1033e1ff0 Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Sun, 31 May 2026 15:48:08 +0800 Subject: [PATCH] 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) --- .agents/docs/ink-divergences.md | 11 ++++ .../integration/layout/prop-reset.test.tsx | 64 +++++++++++++++++++ .../unit/yoga-prop-reset.test.ts | 46 +++++++++++++ packages/runtime/src/host/yoga.ts | 7 ++ 4 files changed, 128 insertions(+) diff --git a/.agents/docs/ink-divergences.md b/.agents/docs/ink-divergences.md index 1a696e4..c7b93de 100644 --- a/.agents/docs/ink-divergences.md +++ b/.agents/docs/ink-divergences.md @@ -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 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 ### React concurrent mode diff --git a/packages/runtime-tests/integration/layout/prop-reset.test.tsx b/packages/runtime-tests/integration/layout/prop-reset.test.tsx index 3b31094..541ba52 100644 --- a/packages/runtime-tests/integration/layout/prop-reset.test.tsx +++ b/packages/runtime-tests/integration/layout/prop-reset.test.tsx @@ -263,6 +263,70 @@ test("marginX composes with marginLeft (specific edge wins) (Blocker 1)", async 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(() => () => ( + + X + + )); + + 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(() => () => ( + + X + + )); + + 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(() => () => ( + + + hidden + + shown + + )); + + const { lastFrame } = await render(Dynamic, { columns: 100 }); + expect(lastFrame({ trimLines: true })).toBe("shown"); +}); + test("reset position to relative on removal (G19)", async () => { // position=absolute with offsets removes the box from flow and moves it visually; // removing 'position' should restore relative positioning (back in flow at top). diff --git a/packages/runtime-tests/unit/yoga-prop-reset.test.ts b/packages/runtime-tests/unit/yoga-prop-reset.test.ts index b50b90a..eebc413 100644 --- a/packages/runtime-tests/unit/yoga-prop-reset.test.ts +++ b/packages/runtime-tests/unit/yoga-prop-reset.test.ts @@ -12,6 +12,9 @@ import { createBox } from "../../runtime/src/host/nodes.ts"; const EDGE_LEFT = 0; const EDGE_TOP = 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 // 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); 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); +}); diff --git a/packages/runtime/src/host/yoga.ts b/packages/runtime/src/host/yoga.ts index c80e070..9715134 100644 --- a/packages/runtime/src/host/yoga.ts +++ b/packages/runtime/src/host/yoga.ts @@ -402,6 +402,13 @@ const RESETTABLE_PROPS = new Set([ "paddingLeft", "paddingRight", "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(