From 46109d5d23b6afc84eed2032d6367cddc5244916 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Tue, 21 Apr 2026 19:45:59 -0600 Subject: [PATCH] fix(chess/ui): ParamField enum rendering under Zod 4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inspector read ZodEnum options via _def.values, which Zod 4 renamed to _def.entries (a Record) and additionally exposes as a public .options array. Under Zod 4 _def.values is undefined, so the inspector crashed on any primitive with an enum param (block-move-type: moveType, override-promotion: target, on-turn-end: color) — the ParamField tried to iterate an undefined options array and threw Cannot read properties of undefined. The browser trapped the render error in a React error boundary, so the surrounding visual builder partly stopped updating; in Playwright the error manifested as a freshly added trigger block never appearing in the DOM. SSR snapshot tests did not catch it because react-dom server-render absorbs the first error silently. Fix: prefer the public .options array; fall back to Object.values of _def.entries for Zod 4; finally fall back to _def.values for Zod 3 compat; empty array as last resort. Also: two snapshot tests documented the pre-existing crash via toThrow. They now snapshot the (valid) rendered output instead and the fresh golden HTML was captured for block-move-type (moveType enum: step/slide/capture) and override-promotion (target enum: queen/ rook/bishop/knight/pawn/disabled). --- .../chess/src/ui/ParamField.snapshot.test.tsx | 18 ++++++++---------- packages/chess/src/ui/ParamField.tsx | 17 +++++++++++++++-- .../ParamField.snapshot.test.tsx.snap | 16 ++++++++++++++++ 3 files changed, 39 insertions(+), 12 deletions(-) diff --git a/packages/chess/src/ui/ParamField.snapshot.test.tsx b/packages/chess/src/ui/ParamField.snapshot.test.tsx index 3bfd086..ce04014 100644 --- a/packages/chess/src/ui/ParamField.snapshot.test.tsx +++ b/packages/chess/src/ui/ParamField.snapshot.test.tsx @@ -145,21 +145,19 @@ describe("ParamField rendering (T14 regression baseline)", () => { }); /** * `block-move-type` and `override-promotion` use `z.enum([...])`. - * The inspector's current introspection reads `_def.values`, which - * is `undefined` on Zod v4's ZodEnum (shape renamed to `entries`). - * That means the inline component throws before rendering for these - * kinds — a pre-existing behaviour the pure refactor must preserve. - * The assertion below pins the throw so the extracted `ParamField` - * continues to reproduce it exactly. + * Previously the inspector read `_def.values`, which is `undefined` + * on Zod v4's ZodEnum (renamed to `entries`), causing a render + * crash. ParamField now reads `.options` / `_def.entries` and + * renders cleanly for both kinds. */ - it("block-move-type throws today (pre-existing z.enum bug)", () => { - expect(() => render("block-move-type")).toThrow(); + it("block-move-type renders moveType enum", () => { + expect(render("block-move-type")).toMatchSnapshot(); }); it("modify-movement-range renders delta", () => { expect(render("modify-movement-range")).toMatchSnapshot(); }); - it("override-promotion throws today (pre-existing z.enum bug)", () => { - expect(() => render("override-promotion")).toThrow(); + it("override-promotion renders target enum", () => { + expect(render("override-promotion")).toMatchSnapshot(); }); it("add-aura renders radius + targetAttr + delta", () => { expect(render("add-aura")).toMatchSnapshot(); diff --git a/packages/chess/src/ui/ParamField.tsx b/packages/chess/src/ui/ParamField.tsx index 28fb380..dad9953 100644 --- a/packages/chess/src/ui/ParamField.tsx +++ b/packages/chess/src/ui/ParamField.tsx @@ -219,7 +219,15 @@ export function ParamField({ type ZodWrappedDefInternal = { _def: { innerType?: ZodType; schema?: ZodType }; }; - type ZodEnumDefInternal = { _def: { values: readonly string[] } }; + /** + * Zod v4 renamed `_def.values` to `_def.entries` (a `Record`) and also exposes the option list as `.options`. We read + * whichever is populated, preferring the public `options` array. + */ + type ZodEnumDefInternal = { + options?: readonly string[]; + _def: { values?: readonly string[]; entries?: Record }; + }; const shape = (primitive.paramsSchema as unknown as ZodObjectInternal).shape; const params = (node.params as Record) || {}; @@ -246,7 +254,12 @@ export function ParamField({ else if (currentSchema instanceof z.ZodBoolean) type = 'boolean'; else if (currentSchema instanceof z.ZodEnum) { type = 'enum'; - options = (currentSchema as unknown as ZodEnumDefInternal)._def.values; + const enumSchema = currentSchema as unknown as ZodEnumDefInternal; + options = + enumSchema.options ?? + (enumSchema._def.entries + ? (Object.values(enumSchema._def.entries) as readonly string[]) + : enumSchema._def.values ?? []); } else if (currentSchema instanceof z.ZodArray) type = 'array'; diff --git a/packages/chess/src/ui/__snapshots__/ParamField.snapshot.test.tsx.snap b/packages/chess/src/ui/__snapshots__/ParamField.snapshot.test.tsx.snap index 9579c1e..6fa055a 100644 --- a/packages/chess/src/ui/__snapshots__/ParamField.snapshot.test.tsx.snap +++ b/packages/chess/src/ui/__snapshots__/ParamField.snapshot.test.tsx.snap @@ -50,6 +50,14 @@ exports[`ParamField rendering (T14 regression baseline) > add-to-attribute rende }

Wrapped in on-turn-start, restores 1 HP to this piece at the start of its color's turn.

" `; +exports[`ParamField rendering (T14 regression baseline) > block-move-type renders moveType enum 1`] = ` +"

Filters out generated moves matching the given type. Multiple block primitives accumulate into a blocked-move-type set (deduped). Useful for pacifist pieces that still slide, or for pieces that can capture but not reposition silently.

Examples
Pacifist piece
{
+  "moveType": "capture"
+}

Piece can step and slide freely but cannot capture — a pure support piece.

Charge-only attacker
{
+  "moveType": "step"
+}

Removes simple step moves; piece can only capture or slide.

" +`; + exports[`ParamField rendering (T14 regression baseline) > conditional renders complex-schema JSON fallback 1`] = ` "

Branches on a condition. If true → runs every primitive in \`then\`; if false and \`else\` is set → runs \`else\`. Condition types: attr-lt (numeric less-than), attr-gt (numeric greater-than), attr-eq (exact match against string/number/boolean/null), always (unconditional then), never (forces else path only).

Examples
Low-HP fortress
{
   "condition": {
@@ -169,6 +177,14 @@ exports[`ParamField rendering (T14 regression baseline) > on-turn-start renders
 ]
" `; +exports[`ParamField rendering (T14 regression baseline) > override-promotion renders target enum 1`] = ` +"

Forces this piece (typically a pawn) to promote to a specific type regardless of player choice. Mirrors the built-in Promotion Override modifier, but expressable inside a custom primitive tree. Last write wins if multiple sources set it.

Examples
Knights-only promotion
{
+  "target": "knight"
+}

Pawn always promotes to a knight.

Underpromote to rook
{
+  "target": "rook"
+}

Pawn always promotes to a rook — useful for themed variants.

" +`; + exports[`ParamField rendering (T14 regression baseline) > reflect-damage renders percentage 1`] = ` "

Reflects a percentage of incoming damage back to the attacker. Integer percent, 0-100. Multiple reflect primitives on the same piece do NOT stack — the most recent value wins. Great inside on-damaged if you want a one-time thorns reaction instead of a permanent aura.

Examples
Half-reflective armour
{
   "percentage": 50