fix(runtime): validate custom borderStyle object shape at render (#172)

A malformed custom borderStyle OBJECT (e.g. `{ topLeft, topRight }` missing
`top`) — or a truthy non-string non-object value (e.g. a number from a JS
caller) — bypassed assertBoxValid's render-time check, which only shape-checked
the STRING form. It reached drawBorder, passed the `if (!chars)` guard, and
threw `Cannot read properties of undefined (reading 'repeat')` deep in the
post-flush PAINT pass — wedging Vue's scheduler instead of surfacing a
recoverable error, exactly the failure mode box-validate.ts exists to prevent.

Resolve borderStyle to a BoxStyle the same way paint's drawBorder does (string
-> cliBoxes[name], object -> directly), then shape-check the result: every one
of the 8 glyphs paint reads (top/bottom/left/right + the four corners) must be
a string. Any invalid value now throws a clean error AT RENDER, caught by the
error boundary — like the existing unknown-string case. The string case keeps
its "Unknown borderStyle:" wording; the object/non-string case uses "Invalid
borderStyle:".

Test-first: borders.test.tsx now asserts a malformed object, a number, each
individually-missing glyph, and a present-but-non-string glyph all reject at
render (not the opaque paint TypeError), and a complete custom object still
paints a border.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yunfei He
2026-06-14 19:44:27 +08:00
committed by GitHub
parent 71e7d7e2f0
commit df843b67fe
2 changed files with 205 additions and 24 deletions
@@ -1675,6 +1675,141 @@ test("non-throwing borderStyle (false/undefined/valid/object) renders normally",
expect(objectFrame).toContain("H");
});
// A MALFORMED custom BoxStyle OBJECT (missing a glyph paint reads, e.g. `top`)
// must REJECT at RENDER with a clean error — exactly like an unknown STRING —
// rather than slipping past validation and throwing an opaque TypeError
// (`Cannot read properties of undefined (reading 'repeat')`) deep in the
// post-flush PAINT pass, where a throw wedges Vue's scheduler. Before the fix,
// assertBoxValid only shape-checked STRING borderStyles, so an object form
// bypassed the check entirely and reached `chars.top.repeat(...)` in
// drawBorder. (companion of audit 2.3 — same paint-wedge failure mode.)
test("MALFORMED borderStyle OBJECT (missing glyph) rejects at render", async ({ expect }) => {
await expect(
render(
defineComponent(() => () => (
// Missing `top` (and the other edges/glyphs paint reads). Reachable in
// JS or via a partial object the consumer believes is a full BoxStyle.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
<Box borderStyle={{ topLeft: "A", topRight: "C" } as any} alignSelf="flex-start">
<Text>Hi</Text>
</Box>
)),
{ columns: 100 },
),
).rejects.toThrow(/Invalid borderStyle/);
// It must NOT surface the opaque paint-phase TypeError.
await expect(
render(
defineComponent(() => () => (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
<Box borderStyle={{ topLeft: "A", topRight: "C" } as any} alignSelf="flex-start">
<Text>Hi</Text>
</Box>
)),
{ columns: 100 },
),
).rejects.not.toThrow(/Cannot read properties of undefined/);
});
// A truthy NON-STRING NON-OBJECT borderStyle (e.g. a number from a JS caller)
// hits the same validation gap and must also reject cleanly at render rather
// than crash in paint.
test("non-string non-object borderStyle (number) rejects at render", async ({ expect }) => {
await expect(
render(
defineComponent(() => () => (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
<Box borderStyle={42 as any} alignSelf="flex-start">
<Text>Hi</Text>
</Box>
)),
{ columns: 100 },
),
).rejects.toThrow(/Invalid borderStyle/);
});
// Exercise the FULL "every one of the 8 glyphs paint reads must be a string"
// contract: an object that is complete EXCEPT one glyph (each, table-driven)
// must reject at render — paint reads top/bottom/left/right unconditionally and
// the four corners when an adjacent side is drawn, so any one missing glyph can
// reach a `.repeat(...)`/concat on a non-string mid-commit.
const COMPLETE_GLYPHS = {
topLeft: "A",
top: "B",
topRight: "C",
right: "D",
bottomRight: "E",
bottom: "F",
bottomLeft: "G",
left: "H",
} as const;
for (const glyph of Object.keys(COMPLETE_GLYPHS)) {
test(`borderStyle OBJECT missing only "${glyph}" rejects at render`, async ({ expect }) => {
const partial = { ...COMPLETE_GLYPHS } as Record<string, string>;
delete partial[glyph];
await expect(
render(
defineComponent(() => () => (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
<Box borderStyle={partial as any} alignSelf="flex-start">
<Text>Hi</Text>
</Box>
)),
{ columns: 100 },
),
).rejects.toThrow(/Invalid borderStyle/);
});
}
// A glyph that is PRESENT but NON-STRING (e.g. a number) must also reject — the
// shape check requires `typeof === "string"`, not mere presence, since paint
// calls `.repeat(...)`/concatenates the glyph as a string.
test("borderStyle OBJECT with a non-string glyph rejects at render", async ({ expect }) => {
await expect(
render(
defineComponent(() => () => (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
<Box borderStyle={{ ...COMPLETE_GLYPHS, top: 5 } as any} alignSelf="flex-start">
<Text>Hi</Text>
</Box>
)),
{ columns: 100 },
),
).rejects.toThrow(/Invalid borderStyle/);
});
// Regression guard: a COMPLETE custom BoxStyle object (all 8 glyphs paint reads,
// as strings) must still render its border, never rejecting. This pins that the
// shape check accepts a genuinely valid object.
test("COMPLETE custom borderStyle OBJECT renders a border (regression)", async ({ expect }) => {
const { lastFrame } = await render(
defineComponent(() => () => (
<Box
borderStyle={{
topLeft: "A",
top: "B",
topRight: "C",
right: "D",
bottomRight: "E",
bottom: "F",
bottomLeft: "G",
left: "H",
}}
alignSelf="flex-start"
>
<Text>Hi</Text>
</Box>
)),
{ columns: 100 },
);
const frame = stripAnsi(lastFrame()!);
const lines = frame.split("\n");
// Top row: A + B...B + C ; content row: H...D ; bottom row: G + F...F + E
expect(lines[0]).toMatch(/^AB+C$/);
expect(lines[1]).toMatch(/^H.*D$/);
expect(lines[2]).toMatch(/^GF+E$/);
});
// borderDimColor should not dim styled child Text touching left edge
test("borderDimColor does not dim styled child Text touching left edge", async ({ expect }) => {
const { lastFrame } = await render(