Big News: Socket raises $60M Series C at a $1B valuation to secure software supply chains for AI-driven development.Announcement
Sign In

@ecopages/jsx

Package Overview
Dependencies
Maintainers
1
Versions
25
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@ecopages/jsx

latest
npmnpm
Version
0.3.0-alpha.25
Version published
Maintainers
1
Created
Source

Radiant JSX

Radiant JSX is the JSX authoring layer for the Radiant ecosystem.

Use @ecopages/jsx when you want TSX syntax, intrinsic element typing, SSR serialization, hydration support, and direct DOM mounting. Keep using @ecopages/radiant when you need component classes, decorators, lifecycle, and reactive host state.

Version License

Start Here

The shortest accurate mental model is:

  • JSX produces a renderer-neutral template result.
  • The same template result can go to the DOM renderer or the SSR renderer.
  • Fine-grained updates happen at bound child ranges, not through a hook scheduler.

The package surface is intentionally split by use case:

  • @ecopages/jsx is the full authoring surface. Use it when a file needs both JSX primitives and rendering helpers.
  • @ecopages/jsx/client is the browser mounting surface. Use it when code should stay client-only.
  • @ecopages/jsx/server is the SSR surface. Use it when code should stay server-only.
  • @ecopages/jsx/jsx-runtime and @ecopages/jsx/jsx-dev-runtime are the automatic runtime entry points used by TypeScript and bundlers.

What This Package Does

@ecopages/jsx provides:

  • automatic JSX runtime entry points through @ecopages/jsx/jsx-runtime and @ecopages/jsx/jsx-dev-runtime
  • typed intrinsic HTML and SVG elements
  • plain function components and fragments
  • primary DOM event bindings with on:*
  • explicit native escape-hatch bindings with on-native:*
  • explicit property bindings with prop:*
  • boolean, data, aria, class, className, classes, and style normalization
  • direct DOM mounting with createRoot(...)
  • HTML string rendering with renderToString(...)
  • hydration markers and DOM hydration helpers
  • direct signal-like child bindings through get() and subscribe(...)
  • subscribable child adapters through createSubscribableJsxValue(...)

Use @ecopages/jsx/server for server-only helpers such as renderToString(...), server custom-element render hooks, and SSR hydration binding scope helpers for framework adapters.

@ecopages/jsx does not provide component state, hooks, decorators, or a standalone component model. Those stay in @ecopages/radiant.

Signal-like values that expose get() and subscribe(...) can be passed directly as child bindings. createSubscribableJsxValue(...) is the adapter case: use it when an external store, emitter, or subscription source does not already speak the signal-like shape but still needs fine-grained child updates. The package consumes reactive values here; it does not define its own full signal system.

Package Scope

@ecopages/jsx owns the rendering primitives for JSX authoring and output.

  • TSX authoring compiles to the package runtime entrypoints.
  • The runtime produces a renderer-neutral template result and related renderable values.
  • The DOM renderer mounts and hydrates those values in the browser.
  • The server renderer serializes the same values to HTML.

Entrypoints

Choose the narrowest entrypoint that matches the environment you are writing for.

EntrypointUse it forIncludes
@ecopages/jsxshared library code, examples, and app code that wants one import pathJSX primitives, DOM mounting, hydration, SSR rendering, advanced SSR hooks, and shared types
@ecopages/jsx/clientbrowser entry files and DOM-only helpersJSX primitives, DOM mounting, hydration, and shared renderable types
@ecopages/jsx/serverSSR adapters, Node or Bun HTML rendering, and custom server-element hooksrenderToString(...), custom-element render hooks, and hydration binding scope helpers
@ecopages/jsx/jsx-runtimeautomatic JSX runtime wiringjsx, jsxs, Fragment, and runtime JSX types
@ecopages/jsx/jsx-dev-runtimedev-mode automatic JSX runtime wiringdevelopment runtime alias for toolchains that emit jsxDEV(...)

If a module is environment-specific, prefer the subpath import even when the root barrel would also work. That keeps browser-only and server-only code obvious at the import site.

SSR Integration Scopes

renderToString(...) emits hydration markers by consuming one hydrate binding sequence. Most app code should let the renderer own that sequence implicitly.

Framework adapters are the exception. If an integration composes one page from multiple sibling renderToString(...) calls, those calls must share one binding namespace so the client sees one continuous marker stream for that hydration root.

Use createServerHydrationBindingState() and withServerHydrationBindingState(...) from @ecopages/jsx/server when you need that explicit control:

  • share one binding state across sibling page, layout, and document-shell renders that belong to the same client-owned root
  • fork a fresh binding state for a nested SSR root, such as an intrinsic custom-element host that hydrates independently

At nested custom-element boundaries, the parent hydration root still owns the custom-element host itself. The nested root starts at the host's rendered internal subtree, not at the host tag.

If you only call renderToString(...) once for a root, you do not need these helpers.

Install And Configure

Install both packages:

npm install @ecopages/radiant @ecopages/jsx

Minimum TypeScript setup:

{
	"compilerOptions": {
		"jsx": "react-jsx",
		"jsxImportSource": "@ecopages/jsx"
	}
}

Or enable it per file:

/** @jsxImportSource @ecopages/jsx */

Quick Start With Radiant

The most common path is a RadiantElement that returns JSX from render().

/** @jsxImportSource @ecopages/jsx */
import { RadiantElement, customElement, prop } from '@ecopages/radiant';

const CounterButton = ({ label, onPress }: { label: string; onPress: (event: MouseEvent) => void }) => (
	<button type="button" on:click={onPress} aria={{ label }}>
		{label}
	</button>
);

@customElement('radiant-counter')
export class RadiantCounter extends RadiantElement {
	@prop({ type: Number, reflect: true, defaultValue: 0 }) count!: number;

	private readonly increment = () => {
		this.count += 1;
	};

	private readonly decrement = () => {
		this.count -= 1;
	};

	override render() {
		return (
			<section class="counter" data={{ state: this.count > 0 ? 'active' : 'idle' }}>
				<h2>Count: {this.count}</h2>
				<div class="controls">
					<CounterButton label="Decrement" onPress={this.decrement} />
					<CounterButton label="Increment" onPress={this.increment} />
				</div>
			</section>
		);
	}
}

Rendering Pipeline

There is one authoring output and two rendering targets.

flowchart TD
    View["JSX view"] --> Template["Template result"]
    Template --> Client["createRoot(...).render(...) or hydrate(...)"]
    Template --> Server["renderToString(...)"]
    Server --> Html["HTML string"]
    Html --> HydrateHtml["Optional hydration markers"]
    HydrateHtml --> Client

This is the key simplification:

  • JSX authoring is shared.
  • DOM rendering and SSR are different consumers of the same structure.
  • Hydration is not a second authoring mode. It is an SSR output mode plus a client attach step.

Internal Architecture

The public entrypoints stay small because the implementation is split by responsibility instead of bundling DOM and SSR behavior into one renderer file.

flowchart LR
	Runtime["jsx-runtime"] --> Values["template results and renderable values"]
	Values --> Client["dom-render"]
	Values --> Server["server-render"]
	Client --> DomInternals["dom-render/* reconciliation, hydration, namespaces, event delegation"]
	Server --> Html["HTML string output"]

Read it like this:

  • jsx-runtime owns authoring output, child-slot semantics, attribute normalization, and runtime value wrappers.
  • dom-render owns DOM mounting, reconciliation, and hydration.
  • server-render owns HTML serialization of the same renderable values.
  • dom-render/* holds the browser-only internals so the public surface can stay small.

Direct DOM Usage

If you need app-level mounting outside a RadiantElement, use the DOM root helper.

/** @jsxImportSource @ecopages/jsx */
import { createRoot } from '@ecopages/jsx/client';

function DirectHandlers() {
	function handleClick() {
		console.log('Click');
	}

	const handleInput = (event: Event) => {
		console.log((event.currentTarget as HTMLInputElement).value);
	};

	return (
		<>
			<button on:click={handleClick}>Log click</button>
			<button on-native:click={handleClick}>Always native click</button>
			<input on:input={handleInput} />
		</>
	);
}

const container = document.querySelector('#app');

if (container instanceof HTMLElement) {
	const root = createRoot(container);
	root.render(<DirectHandlers />);
}

Event Handling

Browser events are simpler than they first look. Something happens, the browser creates an Event object, and that event starts on the node where the interaction actually occurred. That original node is event.target.

From there, the event may travel through the DOM tree. Most UI events you care about, like click, input, and keydown, bubble upward. When a handler runs, event.currentTarget is the element whose listener is currently executing. That difference matters: target answers "where did this start?" and currentTarget answers "which listener is running right now?"

Event delegation is just using that travel on purpose. Instead of attaching one listener to every matching element, you attach fewer listeners higher in the tree and react based on where the event started. That is often a good trade for repeated interactive UI such as lists, tables, menus, and boards. The catch is that delegation only works for events that bubble, and it changes where the real DOM listener lives.

Radiant keeps those choices explicit instead of hiding them behind a synthetic event system.

Use thisWhen you wantRuntime shape
on:*The normal event APIRadiant delegates a fixed allowlist of bubbling events and falls back to direct listeners otherwise
on-native:*Exact element-level browser semantics even for delegated eventsRadiant calls addEventListener(...) on the element itself

on:* is the default. For the delegated allowlist, Radiant attaches one listener per event type on the render root and dispatches to matched elements. For everything else, on:* attaches directly on the element. In both cases the handler receives the native browser event object.

The delegated allowlist is fixed and documented: beforeinput, click, contextmenu, dblclick, focusin, focusout, input, keydown, keyup, mousedown, mouseout, mouseover, mouseup, pointerdown, pointerout, pointerover, pointerup, touchend, touchmove, and touchstart.

Use on-native:* when exact element-level attachment semantics matter, such as when an ancestor stops bubbling before the root listener runs or when you want to opt out of delegation for a supported bubbling event. Events outside the allowlist, such as focus, blur, scroll, load, invalid, or dragstart, already attach directly when authored with on:*.

Fine-Grained Updates

The main non-obvious part of the package is that it already supports child-range subscriptions. A parent tree does not need to rerender when one bound child value changes.

sequenceDiagram
    participant App
    participant Root
    participant Renderer
    participant Binding as Subscribable child
    participant Dom as DOM range

    App->>Root: render(view)
    Root->>Renderer: mount template
    Renderer->>Binding: subscribe(notify)
    Binding-->>Renderer: getValue()
    Renderer->>Dom: mount initial child content
    Binding-->>Renderer: notify(nextValue)
    Renderer->>Dom: patch owned child range only

Pass a signal-like child value directly when it already exposes get() and subscribe(...). Use createSubscribableJsxValue(...) when a child value has its own update source but does not already match that shape. Think of it as a small renderer adapter, not a separate state model.

/** @jsxImportSource @ecopages/jsx */
import { createRoot, createSubscribableJsxValue } from '@ecopages/jsx/client';

let count = 0;
const subscribers = new Set<(value: number) => void>();

const boundCount = createSubscribableJsxValue({
	getValue: () => count,
	subscribe: (notify) => {
		subscribers.add(notify);

		return () => {
			subscribers.delete(notify);
		};
	},
});

const root = createRoot(document.querySelector('#app') as HTMLElement);
root.render(<p>Count: {boundCount}</p>);

count += 1;

for (const subscriber of subscribers) {
	subscriber(count);
}

That contract is intentionally small. The package does not impose a single state container, computed graph, or scheduler. It just gives the renderer a stable subscription surface for either signal-like values or explicit subscribable wrappers.

Empty Values And Removal

Most code should use normal JavaScript values for empty output and removal semantics.

Use this rule of thumb:

  • null, undefined, and false render no child content
  • null and undefined remove normal attributes
  • false removes boolean attributes such as hidden or disabled
  • null removes delegated and native event handlers by omitting the next listener
  • undefined clears deferred property bindings by writing undefined

For child content:

/** @jsxImportSource @ecopages/jsx */

const nextLabel = shouldShowLabel ? 'Ready' : null;

return <p>{nextLabel}</p>;

For attributes and bindings:

/** @jsxImportSource @ecopages/jsx */

return (
	<button
		class={shouldResetClass ? null : 'toolbar-action'}
		hidden={isVisible ? false : true}
		on:click={isInteractive ? handleClick : null}
		prop:payload={hasPayload ? payload : undefined}
	/>
);

Important consequence: removing a binding by switching to null, undefined, or false follows normal template update semantics. If that changes the template shape, the renderer may replace the affected DOM node instead of preserving the previously committed instance.

Dev Warnings

The runtime warnings are intentionally defensive around hydration markers and renderer-owned DOM anchors because those failures are otherwise silent and hard to debug.

They are already off in production by default. In development, you can force them on or off globally:

import { setDevWarningsEnabled } from '@ecopages/jsx/jsx-dev-runtime';

setDevWarningsEnabled(false);
setDevWarningsEnabled(true);
setDevWarningsEnabled(undefined);

Pass undefined to return to the default behavior, which is "on in development, off in production".

SSR And Hydration

renderToString(...) now has two explicit output modes:

  • mode: 'plain' emits plain HTML without hydration markers
  • mode: 'hydrate' emits hydratable HTML with binding markers
/** @jsxImportSource @ecopages/jsx */
import { renderToString } from '@ecopages/jsx/server';

const view = (
	<button class="action" hidden={false} aria={{ label: 'Ship order' }}>
		Ship
	</button>
);

const html = renderToString(view, { mode: 'plain' });
const hydratedHtml = renderToString(view, { mode: 'hydrate' });

Hydrated SSR adds binding markers so hydrate(...) can attach listeners and dynamic parts without rebuilding the existing DOM tree.

SSR-Capable Custom Elements

Within the JSX SSR pipeline, any registered intrinsic tag containing - is treated as a custom-element candidate.

There are three practical outcomes during SSR:

  • generic SSR-capable custom elements implement renderHostToString(...) and can be serialized directly by @ecopages/jsx/server
  • framework-owned custom elements, such as RadiantElement, are adapted through the server custom-element render hook so JSX does not need framework-specific branches in its core renderer
  • plain registered custom elements without an SSR contract fall back to their authored markup so the client can still upgrade them later

The generic contract is intentionally small:

import type { RenderToStringOptions } from '@ecopages/jsx/server';

type ServerRenderableCustomElement = {
	renderHostToString(options?: RenderToStringOptions): string;
};

That means JSX itself does not distinguish between "Radiant custom element" and "standard custom element" as first-class renderer categories. Instead, it understands one generic SSR-capable shape plus one hook seam for frameworks that need richer host rendering.

Use withServerCustomElementRenderHook(...) from @ecopages/jsx/server when a framework wants to intercept a registered custom-element instance and replace the default generic SSR path with framework-aware host rendering.

Authoring Patterns

Intrinsic Elements

const view = (
	<section>
		<h2>Status</h2>
		<svg viewBox="0 0 24 24" aria={{ hidden: true }}>
			<circle cx="12" cy="12" r="10" />
		</svg>
	</section>
);

Custom Elements

When jsxImportSource points at @ecopages/jsx, custom elements should augment the runtime module instead of the global JSX namespace.

import type { JsxCustomElementAttributes } from '@ecopages/jsx';

type UserCardProps = {
	name: string;
	isAdmin: boolean;
};

declare module '@ecopages/jsx/jsx-runtime' {
	interface JsxCustomIntrinsicElements {
		'user-card': JsxCustomElementAttributes<HTMLElement, UserCardProps>;
	}
}

Custom elements default to property bindings for unprefixed names, with a small attribute-default set for obvious HTML semantics: id, class, style, title, role, slot, part, tabindex, hidden, lang, dir, plus expanded data-* and aria-*. Use attr:* when a non-default name must serialize to markup, and prop:* when you want to override the default explicitly.

Typing follows the same ergonomic split. Put public unprefixed JSX props on Props, and use the element instance type for explicit prop:* bindings. Props keeps its own required and optional fields, so required public JSX props stay required. That means items={rows} is typed from Props, while prop:api={gridApi} is typed from the custom element class property.

<user-grid id="people" class="panel" items={rows} selection={currentRow} attr:status="ready" prop:api={gridApi} />
import type { JsxCustomElementAttributes } from '@ecopages/jsx';

type UserGridRow = {
	id: string;
};

type UserGridProps = {
	items: UserGridRow[];
	selection?: UserGridRow;
};

class UserGridElement extends HTMLElement {
	api?: UserGridApi;
}

type UserGridApi = {
	focusRow(id: string): void;
};

declare module '@ecopages/jsx/jsx-runtime' {
	interface JsxCustomIntrinsicElements {
		'user-grid': JsxCustomElementAttributes<UserGridElement, UserGridProps>;
	}
}

const rows: UserGridRow[] = [{ id: '1' }];
const currentRow = rows[0];
const gridApi: UserGridApi = {
	focusRow: (_id) => undefined,
};

<user-grid items={rows} selection={currentRow} prop:api={gridApi} />;

Function Components And Fragments

type CardProps = {
	title: string;
	children?: import('@ecopages/jsx').JsxRenderable;
};

const Card = ({ title, children }: CardProps) => (
	<>
		<article class="card">
			<h2>{title}</h2>
			{children}
		</article>
	</>
);

Event Bindings

<button on:click={this.handleClick}>Save</button>
<button on-native:click={this.handleNativeClick}>Save with native attachment</button>

on:* is the normal event API. Radiant automatically delegates compatible bubbling events for efficiency and attaches directly for everything else. on-native:* is the escape hatch when the handler must behave exactly like a browser listener attached on that element. Neither mode wraps the browser event in a React-style synthetic object.

Property Bindings

<custom-editor prop:value={draft} prop:config={editorConfig} />

Use prop:* when the target must receive a real property value instead of a serialized attribute, or when you want to be explicit even though custom elements already default most unprefixed names to properties.

Structured data, aria, class, and style

<section
	class={['panel', isActive && 'panel--active']}
	classes={['surface', { interactive: true }]}
	style={{ backgroundColor: 'white', fontSize: '14px' }}
	data={{ tid: 'panel', state: 'ready' }}
	aria={{ live: 'polite' }}
/>

Runtime Output Contract

jsx() and jsxs() return a template result object that contains:

  • jsx() is emitted when JSX produces one logical child value

  • jsxs() is emitted when JSX produces multiple sibling child values

  • static string segments

  • dynamic values

  • a stable marker used by the Radiant renderers to recognize the object shape

That distinction is not cosmetic. It preserves child-slot structure from the automatic JSX transform.

const single = <div>{items.map(renderItem)}</div>;
const siblings = (
	<div>
		<span>A</span>
		<span>B</span>
	</div>
);

The first case compiles to jsx(...) because the div has one logical child expression. Radiant keeps that iterable child grouped as one binding.

The second case compiles to jsxs(...) because the div has multiple sibling children. Radiant expands those siblings into separate positional template slots.

Why keep both instead of one export:

  • TypeScript's automatic JSX runtime emits both names, so removing one would break the compiler contract.
  • Radiant uses the distinction to decide whether children should stay as one grouped value or be split into sibling slots.
  • Guessing later inside one function would lose source-level intent and make the template strings and values shape less predictable.

That object is an internal contract between the JSX runtime and the Radiant renderers. It is not positioned as a generic third-party virtual DOM format.

Trusted Markup

@ecopages/jsx escapes normal text and attribute values by default.

If you already have final, trusted HTML and need to hand it to the runtime as markup, use unsafeHtml(...).

import { unsafeHtml } from '@ecopages/jsx';

const trustedSnippet = unsafeHtml('<strong>Trusted</strong>');

const view = <p>{trustedSnippet}</p>;

Important:

  • this is an unsafe opt-in escape hatch
  • the input is not sanitized
  • the input is not escaped again
  • untrusted user input must not flow through this helper

The runtime treats trusted markup as opaque HTML content. It is inserted as markup for DOM mounting and emitted as-is during SSR, but it does not become a live JSX template or a hydratable binding boundary.

Exported Surface

Main exports:

  • Fragment
  • unsafeHtml
  • jsx
  • jsxs
  • createRoot
  • render
  • hydrate
  • hasHydrationMarkers
  • createSubscribableJsxValue
  • isKeyedJsxValue
  • isSubscribableJsxValue

Key types:

  • JsxComponent
  • JsxFragment
  • JsxIntrinsicAttributes
  • JsxNodeLike
  • JsxPrimitive
  • JsxPropsWithChildren
  • JsxRenderable
  • JsxRoot
  • SubscribableJsxValue
  • TemplateResultLike

The automatic development runtime also exports jsxDEV from @ecopages/jsx/jsx-dev-runtime.

Server-only exports from @ecopages/jsx/server include:

  • renderToString
  • RenderToStringOptions
  • withServerCustomElementRenderHook
  • isServerRenderHydrationActive

Constraints

  • This package is intentionally smaller than React-like frameworks. There is no hook system or component-local scheduler here.
  • @ecopages/jsx handles authoring and rendering primitives. @ecopages/radiant handles component lifecycle and host reactivity.
  • Use renderToString(...) and createRoot(...) directly when you need lower-level control outside a Radiant host.

Why Use It

Use @ecopages/jsx if you want to:

  • author Radiant components with TSX instead of string templates
  • compose plain function components inside custom-element views
  • keep bindings explicit and native instead of hiding them behind a synthetic event layer
  • share one JSX authoring model across DOM rendering and SSR
  • opt into fine-grained child updates without adopting a hook runtime

FAQs

Package last updated on 16 May 2026

Did you know?

Socket

Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.

Install

Related posts