From 54527e9e652d811aa04c3277c7a1f0ad87a1ceb9 Mon Sep 17 00:00:00 2001 From: Elliot Hesp Date: Wed, 11 Mar 2026 20:04:58 +0000 Subject: [PATCH 1/2] fix(chat): handle non-array children in fallback text and Actions types - Add getChildrenArray() to normalize children (array/single/undefined) for JSX/serialization - Use getChildrenArray in cardToFallbackText and cardChildToFallbackText (cards.ts and markdown.ts BaseFormatConverter) to fix 'element.children.map is not a function' - Unify ActionsComponent overloads so Actions([...]) type-checks when nested in Card({ children: [...] }) - Add tests for getChildrenArray and fallback text with non-array children Made-with: Cursor --- packages/chat/src/cards.test.ts | 144 +++++++++++++++++++++++++++++++ packages/chat/src/cards.ts | 27 ++++-- packages/chat/src/jsx-runtime.ts | 21 +++-- packages/chat/src/markdown.ts | 17 ++-- 4 files changed, 191 insertions(+), 18 deletions(-) diff --git a/packages/chat/src/cards.test.ts b/packages/chat/src/cards.test.ts index d9195f94..cf3f665b 100644 --- a/packages/chat/src/cards.test.ts +++ b/packages/chat/src/cards.test.ts @@ -4,9 +4,12 @@ import { Button, Card, CardLink, + cardChildToFallbackText, + cardToFallbackText, Divider, Field, Fields, + getChildrenArray, Image, isCardElement, LinkButton, @@ -339,4 +342,145 @@ describe("Select and RadioSelect Builder Validation", () => { }); }); +describe("getChildrenArray", () => { + it("returns array when children is already an array", () => { + const node = { children: [Text("a"), Text("b")] }; + expect(getChildrenArray(node)).toEqual([ + { type: "text", content: "a", style: undefined }, + { type: "text", content: "b", style: undefined }, + ]); + }); + + it("wraps single child in array when children is not an array", () => { + const node = { children: Text("only") }; + expect(getChildrenArray(node)).toHaveLength(1); + expect(getChildrenArray(node)[0]).toEqual({ + type: "text", + content: "only", + style: undefined, + }); + }); + + it("returns empty array when children is undefined", () => { + expect(getChildrenArray({})).toEqual([]); + expect(getChildrenArray({ children: undefined })).toEqual([]); + }); + + it("returns empty array when children is null", () => { + expect(getChildrenArray({ children: null })).toEqual([]); + }); +}); + +describe("Fallback text with non-array children", () => { + it("cardToFallbackText handles card with children undefined", () => { + const card = { + type: "card" as const, + title: "Test", + subtitle: "Sub", + children: undefined, + }; + expect(() => cardToFallbackText(card)).not.toThrow(); + expect(cardToFallbackText(card)).toContain("**Test**"); + expect(cardToFallbackText(card)).toContain("Sub"); + }); + + it("cardToFallbackText handles card with children null", () => { + const card = { + type: "card" as const, + title: "Test", + children: null, + }; + expect(() => cardToFallbackText(card)).not.toThrow(); + expect(cardToFallbackText(card)).toBe("**Test**"); + }); + + it("cardToFallbackText handles card with single child (non-array)", () => { + const card = { + type: "card" as const, + title: "Test", + children: Text("Only child"), + }; + expect(() => cardToFallbackText(card)).not.toThrow(); + expect(cardToFallbackText(card)).toContain("**Test**"); + expect(cardToFallbackText(card)).toContain("Only child"); + }); + + it("cardChildToFallbackText handles section with children undefined", () => { + const section = { + type: "section" as const, + children: undefined, + }; + expect(() => cardChildToFallbackText(section)).not.toThrow(); + expect(cardChildToFallbackText(section)).toBe(""); + }); + + it("cardChildToFallbackText handles section with children null", () => { + const section = { + type: "section" as const, + children: null, + }; + expect(() => cardChildToFallbackText(section)).not.toThrow(); + expect(cardChildToFallbackText(section)).toBe(""); + }); + + it("cardChildToFallbackText handles section with single child (non-array)", () => { + const section = { + type: "section" as const, + children: Text("Single"), + }; + expect(() => cardChildToFallbackText(section)).not.toThrow(); + expect(cardChildToFallbackText(section)).toBe("Single"); + }); + + it("cardChildToFallbackText handles fields with children undefined", () => { + const fields = { + type: "fields" as const, + children: undefined, + }; + expect(() => cardChildToFallbackText(fields)).not.toThrow(); + expect(cardChildToFallbackText(fields)).toBe(""); + }); + + it("cardChildToFallbackText handles fields with children null", () => { + const fields = { + type: "fields" as const, + children: null, + }; + expect(() => cardChildToFallbackText(fields)).not.toThrow(); + expect(cardChildToFallbackText(fields)).toBe(""); + }); + + it("cardChildToFallbackText handles fields with single field (non-array)", () => { + const fields = { + type: "fields" as const, + children: Field({ label: "Name", value: "Alice" }), + }; + expect(() => cardChildToFallbackText(fields)).not.toThrow(); + expect(cardChildToFallbackText(fields)).toBe("Name: Alice"); + }); + + it("cardToFallbackText with section and fields with non-array children (serialization-style)", () => { + const card = { + type: "card" as const, + title: "Review", + subtitle: "Please confirm", + children: [ + { + type: "section" as const, + children: Text("Approve or reject below."), + }, + { + type: "fields" as const, + children: Field({ label: "Status", value: "Pending" }), + }, + ], + }; + expect(() => cardToFallbackText(card)).not.toThrow(); + const fallback = cardToFallbackText(card); + expect(fallback).toContain("**Review**"); + expect(fallback).toContain("Approve or reject below."); + expect(fallback).toContain("Status: Pending"); + }); +}); + // JSX tests moved to jsx-react.test.tsx and jsx-runtime.test.tsx diff --git a/packages/chat/src/cards.ts b/packages/chat/src/cards.ts index ea6fc0c3..6ae4d1dd 100644 --- a/packages/chat/src/cards.ts +++ b/packages/chat/src/cards.ts @@ -745,6 +745,18 @@ function extractTextContent(children: unknown): string { // Fallback Text Generation // ============================================================================ +/** Ensure children is always an array (handles JSX/serialization where it may be single or undefined). */ +export function getChildrenArray(node: { children?: unknown }): unknown[] { + const c = node.children; + if (Array.isArray(c)) { + return c; + } + if (c != null) { + return [c]; + } + return []; +} + /** * Generate plain text fallback from a CardElement. * Used for platforms/clients that can't render rich cards, @@ -761,8 +773,8 @@ export function cardToFallbackText(card: CardElement): string { parts.push(card.subtitle); } - for (const child of card.children) { - const text = cardChildToFallbackText(child); + for (const child of getChildrenArray(card)) { + const text = cardChildToFallbackText(child as CardChild); if (text) { parts.push(text); } @@ -782,7 +794,12 @@ export function cardChildToFallbackText(child: CardChild): string | null { case "link": return `${child.label} (${child.url})`; case "fields": - return child.children.map((f) => `${f.label}: ${f.value}`).join("\n"); + return getChildrenArray(child) + .map( + (f: unknown) => + `${(f as FieldElement).label}: ${(f as FieldElement).value}` + ) + .join("\n"); case "actions": // Actions are interactive-only — exclude from fallback text. // See: https://docs.slack.dev/reference/methods/chat.postMessage @@ -790,8 +807,8 @@ export function cardChildToFallbackText(child: CardChild): string | null { case "table": return tableElementToAscii(child.headers, child.rows); case "section": - return child.children - .map((c) => cardChildToFallbackText(c)) + return getChildrenArray(child) + .map((c: unknown) => cardChildToFallbackText(c as CardChild)) .filter(Boolean) .join("\n"); default: diff --git a/packages/chat/src/jsx-runtime.ts b/packages/chat/src/jsx-runtime.ts index 108c13b3..a30e9940 100644 --- a/packages/chat/src/jsx-runtime.ts +++ b/packages/chat/src/jsx-runtime.ts @@ -299,15 +299,24 @@ export interface SectionComponent { (props: ContainerProps): ChatElement; } +/** Action child type for Actions() - buttons and selects only. */ +type ActionsChild = + | ButtonElement + | LinkButtonElement + | SelectElement + | RadioSelectElement; + export interface ActionsComponent { + /** + * Array form (preferred): Actions([Button(...), Button(...)]). + * Accepts CardChild[] so that Actions([...]) nested in Card({ children: [...] }) + * type-checks when the array is contextually typed as CardChild[]. + * Runtime still expects only button/select elements. + */ ( - children: ( - | ButtonElement - | LinkButtonElement - | SelectElement - | RadioSelectElement - )[] + children: readonly CardChild[] | readonly ActionsChild[] | ActionsChild[] ): ActionsElement; + /** Props form for JSX: