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 <no-reply@ona.com>
This commit is contained in:
2026-03-14 01:06:22 +00:00
parent 00a5e83e8b
commit 4dcd184de0
6 changed files with 126 additions and 58 deletions

View File

@@ -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<string, unknown>;
/** Child nodes */
children: JrxNode[];
children: JrxElement[];
// -- Reserved / meta fields (extracted from JSX props) --
@@ -58,6 +60,14 @@ export interface JrxNode {
watch: Record<string, ActionBinding | ActionBinding[]> | 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 (`<Card />`), 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<string, unknown>) => JrxNode;
@@ -85,7 +95,7 @@ export type JrxComponent = (props: Record<string, unknown>) => 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<string, unknown>) => {
return (props: Record<string, unknown>): JrxElement => {
return {
$$typeof: JRX_NODE,
type: typeName,
@@ -110,21 +120,21 @@ function filterReserved(props: Record<string, unknown>): Record<string, unknown>
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;