From 4dcd184de000ab3edc3cc62e7945bf50e6720b2e Mon Sep 17 00:00:00 2001 From: Kenneth Date: Sat, 14 Mar 2026 01:06:22 +0000 Subject: [PATCH] Support null and undefined in JrxNode type Split JrxNode into JrxElement (concrete branded object) and JrxNode (JrxElement | null | undefined), mirroring React's ReactElement vs ReactNode pattern. Components can now return null/undefined to render nothing. - Rename isJrxNode to isJrxElement (deprecated alias kept) - Handle null-returning components in jsx/jsxs factories - Add tests for null/undefined returns, children filtering, render() rejection Co-authored-by: Ona --- src/index.ts | 4 +- src/jsx-runtime.ts | 25 +++++++------ src/jsx.test.tsx | 90 +++++++++++++++++++++++++++++++++------------ src/render.test.tsx | 12 +++++- src/render.ts | 18 ++++----- src/types.ts | 35 ++++++++++++------ 6 files changed, 126 insertions(+), 58 deletions(-) diff --git a/src/index.ts b/src/index.ts index de3d532..bc18989 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,3 @@ export { render } from "./render"; -export { isJrxNode, JRX_NODE, FRAGMENT } from "./types"; -export type { JrxNode, JrxComponent, RenderOptions } from "./types"; +export { isJrxElement, isJrxNode, JRX_NODE, FRAGMENT } from "./types"; +export type { JrxElement, JrxNode, JrxComponent, RenderOptions } from "./types"; diff --git a/src/jsx-runtime.ts b/src/jsx-runtime.ts index c4a59ea..eab29d1 100644 --- a/src/jsx-runtime.ts +++ b/src/jsx-runtime.ts @@ -2,7 +2,7 @@ import type { ActionBinding, VisibilityCondition, } from "@json-render/core"; -import { JRX_NODE, FRAGMENT, type JrxNode } from "./types"; +import { JRX_NODE, FRAGMENT, type JrxElement, type JrxNode } from "./types"; import type { JrxComponent } from "./types"; export { FRAGMENT as Fragment }; @@ -18,26 +18,26 @@ const RESERVED_PROPS = new Set([ ]); /** - * Normalize a raw `children` value from JSX props into a flat array of JrxNodes. + * Normalize a raw `children` value from JSX props into a flat array of JrxElements. * Handles: undefined, single node, nested arrays, and filters out nulls/booleans. */ -function normalizeChildren(raw: unknown): JrxNode[] { +function normalizeChildren(raw: unknown): JrxElement[] { if (raw == null || typeof raw === "boolean") return []; if (Array.isArray(raw)) { - const result: JrxNode[] = []; + const result: JrxElement[] = []; for (const child of raw) { if (child == null || typeof child === "boolean") continue; if (Array.isArray(child)) { result.push(...normalizeChildren(child)); } else { - result.push(child as JrxNode); + result.push(child as JrxElement); } } return result; } - return [raw as JrxNode]; + return [raw as JrxElement]; } /** @@ -62,9 +62,9 @@ type JsxType = string | typeof FRAGMENT | JrxComponent; * Core factory — shared by `jsx` and `jsxs`. * * If `type` is a function, it is called with props (like React calls - * function components). The function returns a JrxNode directly. + * function components). The function may return null/undefined. * - * If `type` is a string or Fragment, a JrxNode is constructed inline. + * If `type` is a string or Fragment, a JrxElement is constructed inline. */ function createNode( type: JsxType, @@ -73,6 +73,7 @@ function createNode( const p = rawProps ?? {}; // Function component — call it, just like React does. + // May return null/undefined to render nothing. if (typeof type === "function") { return type(p); } @@ -104,7 +105,9 @@ export function jsx( key?: string, ): JrxNode { const node = createNode(type, props); - if (key != null) node.key = String(key); + // Key is intentionally dropped when a component returns null/undefined — + // there is no element to attach it to. + if (node != null && key != null) node.key = String(key); return node; } @@ -118,7 +121,7 @@ export function jsxs( key?: string, ): JrxNode { const node = createNode(type, props); - if (key != null) node.key = String(key); + if (node != null && key != null) node.key = String(key); return node; } @@ -132,7 +135,7 @@ export namespace JSX { [tag: string]: Record; } - /** The type returned by JSX expressions. */ + /** The type returned by JSX expressions (may be null/undefined). */ export type Element = JrxNode; export interface ElementChildrenAttribute { diff --git a/src/jsx.test.tsx b/src/jsx.test.tsx index 6cf11f1..92ed468 100644 --- a/src/jsx.test.tsx +++ b/src/jsx.test.tsx @@ -1,6 +1,6 @@ import { describe, it, expect } from "bun:test"; import { render } from "./render"; -import { isJrxNode, FRAGMENT } from "./types"; +import { isJrxElement, FRAGMENT } from "./types"; import { jsx, jsxs, Fragment } from "./jsx-runtime"; import { Stack, @@ -18,26 +18,26 @@ import { // ============================================================================= describe("jsx factory", () => { - it("jsx() with string type returns a JrxNode", () => { + it("jsx() with string type returns a JrxElement", () => { const node = jsx("Card", { title: "Hello" }); - expect(isJrxNode(node)).toBe(true); - expect(node.type).toBe("Card"); - expect(node.props).toEqual({ title: "Hello" }); + expect(isJrxElement(node)).toBe(true); + expect(node!.type).toBe("Card"); + expect(node!.props).toEqual({ title: "Hello" }); }); it("jsx() with component function resolves typeName", () => { const node = jsx(Card, { title: "Hello" }); - expect(isJrxNode(node)).toBe(true); - expect(node.type).toBe("Card"); - expect(node.props).toEqual({ title: "Hello" }); + expect(isJrxElement(node)).toBe(true); + expect(node!.type).toBe("Card"); + expect(node!.props).toEqual({ title: "Hello" }); }); - it("jsxs() returns a JrxNode with children", () => { + it("jsxs() returns a JrxElement with children", () => { const node = jsxs(Stack, { children: [jsx(Text, { content: "A" }), jsx(Text, { content: "B" })], }); - expect(isJrxNode(node)).toBe(true); - expect(node.children).toHaveLength(2); + expect(isJrxElement(node)).toBe(true); + expect(node!.children).toHaveLength(2); }); it("Fragment is the FRAGMENT symbol", () => { @@ -46,8 +46,8 @@ describe("jsx factory", () => { it("jsx() extracts key from third argument", () => { const node = jsx(Card, { title: "Hi" }, "my-key"); - expect(node.key).toBe("my-key"); - expect(node.props).toEqual({ title: "Hi" }); + expect(node!.key).toBe("my-key"); + expect(node!.props).toEqual({ title: "Hi" }); }); it("jsx() extracts reserved props", () => { @@ -64,26 +64,70 @@ describe("jsx factory", () => { watch, }); - expect(node.props).toEqual({ label: "Go" }); - expect(node.visible).toEqual(vis); - expect(node.on).toEqual(on); - expect(node.repeat).toEqual(repeat); - expect(node.watch).toEqual(watch); + expect(node!.props).toEqual({ label: "Go" }); + expect(node!.visible).toEqual(vis); + expect(node!.on).toEqual(on); + expect(node!.repeat).toEqual(repeat); + expect(node!.watch).toEqual(watch); }); it("jsx() handles null props", () => { const node = jsx("Divider", null); - expect(isJrxNode(node)).toBe(true); - expect(node.props).toEqual({}); - expect(node.children).toEqual([]); + expect(isJrxElement(node)).toBe(true); + expect(node!.props).toEqual({}); + expect(node!.children).toEqual([]); }); it("jsx() filters null/boolean children", () => { const node = jsxs(Stack, { children: [null, jsx(Text, { content: "A" }), false, undefined, true], }); - expect(node.children).toHaveLength(1); - expect(node.children[0].type).toBe("Text"); + expect(node!.children).toHaveLength(1); + expect(node!.children[0].type).toBe("Text"); + }); +}); + +// ============================================================================= +// Null / undefined component returns +// ============================================================================= + +describe("null/undefined component returns", () => { + it("jsx() with a component returning null produces null", () => { + const NullComponent = (_props: Record) => null; + const node = jsx(NullComponent, {}); + expect(node).toBeNull(); + expect(isJrxElement(node)).toBe(false); + }); + + it("jsx() with a component returning undefined produces undefined", () => { + const UndefinedComponent = (_props: Record) => undefined; + const node = jsx(UndefinedComponent, {}); + expect(node).toBeUndefined(); + expect(isJrxElement(node)).toBe(false); + }); + + it("key argument is ignored when component returns null", () => { + const NullComponent = (_props: Record) => null; + const node = jsx(NullComponent, {}, "my-key"); + expect(node).toBeNull(); + }); + + it("null-returning component children are filtered out", () => { + const NullComponent = (_props: Record) => null; + const node = jsxs(Stack, { + children: [jsx(NullComponent, {}), jsx(Text, { content: "A" })], + }); + expect(node!.children).toHaveLength(1); + expect(node!.children[0].type).toBe("Text"); + }); + + it("undefined-returning component children are filtered out", () => { + const UndefinedComponent = (_props: Record) => undefined; + const node = jsxs(Stack, { + children: [jsx(Text, { content: "A" }), jsx(UndefinedComponent, {})], + }); + expect(node!.children).toHaveLength(1); + expect(node!.children[0].type).toBe("Text"); }); }); diff --git a/src/render.test.tsx b/src/render.test.tsx index 619588b..51c8ad2 100644 --- a/src/render.test.tsx +++ b/src/render.test.tsx @@ -300,7 +300,15 @@ describe("state passthrough", () => { // ============================================================================= describe("error handling", () => { - it("throws for non-JrxNode input", () => { - expect(() => render({} as JrxNode)).toThrow(/expects a JrxNode/); + it("throws for non-JrxElement input", () => { + expect(() => render({} as JrxNode)).toThrow(/expects a JrxElement/); + }); + + it("throws when given null", () => { + expect(() => render(null)).toThrow(/expects a JrxElement/); + }); + + it("throws when given undefined", () => { + expect(() => render(undefined)).toThrow(/expects a JrxElement/); }); }); diff --git a/src/render.ts b/src/render.ts index b372f96..779f829 100644 --- a/src/render.ts +++ b/src/render.ts @@ -1,5 +1,5 @@ import type { Spec, UIElement } from "@json-render/core"; -import { FRAGMENT, type JrxNode, type RenderOptions, isJrxNode } from "./types"; +import { FRAGMENT, type JrxElement, type JrxNode, type RenderOptions, isJrxElement } from "./types"; /** * Flatten a JrxNode tree into a json-render `Spec`. @@ -21,8 +21,8 @@ import { FRAGMENT, type JrxNode, type RenderOptions, isJrxNode } from "./types"; * ``` */ export function render(node: JrxNode, options?: RenderOptions): Spec { - if (!isJrxNode(node)) { - throw new Error("render() expects a JrxNode produced by JSX."); + if (!isJrxElement(node)) { + throw new Error("render() expects a JrxElement produced by JSX."); } if (node.type === FRAGMENT) { @@ -66,12 +66,12 @@ function generateKey( /** * Resolve the children of a node, expanding fragments inline. - * Returns an array of concrete (non-fragment) JrxNodes. + * Returns an array of concrete (non-fragment) JrxElements. */ -function expandChildren(children: JrxNode[]): JrxNode[] { - const result: JrxNode[] = []; +function expandChildren(children: JrxElement[]): JrxElement[] { + const result: JrxElement[] = []; for (const child of children) { - if (!isJrxNode(child)) continue; + if (!isJrxElement(child)) continue; if (child.type === FRAGMENT) { // Recursively expand nested fragments result.push(...expandChildren(child.children)); @@ -83,11 +83,11 @@ function expandChildren(children: JrxNode[]): JrxNode[] { } /** - * Recursively flatten a JrxNode into the elements map. + * Recursively flatten a JrxElement into the elements map. * Returns the key assigned to this node. */ function flattenNode( - node: JrxNode, + node: JrxElement, elements: Record, counters: Map, usedKeys: Set, diff --git a/src/types.ts b/src/types.ts index 75b893c..b18f753 100644 --- a/src/types.ts +++ b/src/types.ts @@ -19,12 +19,14 @@ export const JRX_NODE = Symbol.for("jrx.node"); export const FRAGMENT = Symbol.for("jrx.fragment"); /** - * A node in the intermediate JSX tree. + * A concrete element in the intermediate JSX tree. * * Created by the `jsx` / `jsxs` factory functions and consumed by `render()` * which flattens the tree into a json-render `Spec`. + * + * Analogous to React's `ReactElement`. */ -export interface JrxNode { +export interface JrxElement { /** Brand symbol — always `JRX_NODE` */ $$typeof: typeof JRX_NODE; @@ -38,7 +40,7 @@ export interface JrxNode { props: Record; /** Child nodes */ - children: JrxNode[]; + children: JrxElement[]; // -- Reserved / meta fields (extracted from JSX props) -- @@ -58,6 +60,14 @@ export interface JrxNode { watch: Record | undefined; } +/** + * Any value that can appear as a JSX child or component return value. + * + * Analogous to React's `ReactNode` — includes `null` and `undefined` + * so components can conditionally render nothing. + */ +export type JrxNode = JrxElement | null | undefined; + // --------------------------------------------------------------------------- // JrxComponent — a function usable as a JSX tag that maps to a type string // --------------------------------------------------------------------------- @@ -65,7 +75,7 @@ export interface JrxNode { /** * A jrx component function. Works like a React function component: * when used as a JSX tag (``), the factory calls the function - * with props and gets back a JrxNode. + * with props and gets back a JrxNode (which may be null/undefined). */ export type JrxComponent = (props: Record) => JrxNode; @@ -85,7 +95,7 @@ export type JrxComponent = (props: Record) => JrxNode; export function component(typeName: string): JrxComponent { // Import createNodeFromString lazily to avoid circular dep // (jsx-runtime imports types). Instead, we build the node inline. - return (props: Record) => { + return (props: Record): JrxElement => { return { $$typeof: JRX_NODE, type: typeName, @@ -110,21 +120,21 @@ function filterReserved(props: Record): Record return out; } -function normalizeChildrenRaw(raw: unknown): JrxNode[] { +function normalizeChildrenRaw(raw: unknown): JrxElement[] { if (raw == null || typeof raw === "boolean") return []; if (Array.isArray(raw)) { - const result: JrxNode[] = []; + const result: JrxElement[] = []; for (const child of raw) { if (child == null || typeof child === "boolean") continue; if (Array.isArray(child)) { result.push(...normalizeChildrenRaw(child)); } else { - result.push(child as JrxNode); + result.push(child as JrxElement); } } return result; } - return [raw as JrxNode]; + return [raw as JrxElement]; } // --------------------------------------------------------------------------- @@ -140,10 +150,13 @@ export interface RenderOptions { // Type guard // --------------------------------------------------------------------------- -export function isJrxNode(value: unknown): value is JrxNode { +export function isJrxElement(value: unknown): value is JrxElement { return ( typeof value === "object" && value !== null && - (value as JrxNode).$$typeof === JRX_NODE + (value as JrxElement).$$typeof === JRX_NODE ); } + +/** @deprecated Use `isJrxElement` instead. */ +export const isJrxNode = isJrxElement;