Radiant0.3.0-rc.2

JSX SSR

@ecopages/jsx/server is the dedicated SSR entrypoint for renderToString(...).

It serializes the same JSX authoring output used by the DOM renderer.

The Two SSR Modes

/** @jsxImportSource @ecopages/jsx */
 
import { renderToString } from '@ecopages/jsx/server';
 
const view = <button class="action">Ship</button>;
 
const html = renderToString(view);
const hydratedHtml = renderToString(view, { mode: 'hydrate' });
  • renderToString(view) returns plain HTML
  • renderToString(view, { mode: 'hydrate' }) returns HTML plus binding markers that the client hydrator can reconnect later

Use mode: 'hydrate' only when the client will hydrate that exact same view.

What Hydrated SSR Adds

Hydrated output embeds binding descriptors for dynamic parts such as:

  • event bindings
  • property bindings
  • boolean and normal attribute bindings

Those markers let hydrate(...) attach listeners and property wiring without rebuilding the existing DOM tree.

Client Attach Step

The normal client follow-up is:

/** @jsxImportSource @ecopages/jsx */
 
import { createRoot } from '@ecopages/jsx';
 
function App() {
	return <button class="action">Ship</button>;
}
 
const container = document.querySelector('#app');
 
if (container instanceof HTMLElement) {
	createRoot(container).hydrate(<App />);
}

If hydration markers are missing, the client runtime falls back to a normal render.

SSR And Custom Elements

When JSX SSR encounters a registered Radiant custom element, the server custom-element render hook asks Radiant's server pipeline to serialize the host (same path as renderRadiantElementHostToString from @ecopages/radiant/server/radiant-element-ssr). Radiant Element Hosts do not expose a durable instance renderHostToString() API. That instance method remains only the generic third-party custom-element contract understood by @ecopages/jsx/server.

At SSR time the runtime:

  1. instantiates the registered custom element constructor
  2. applies normalized attributes and serialized children
  3. serializes the host through the Radiant server pipeline (light-DOM only)

Event handlers are not serialized into HTML. They reconnect only during client hydration.

See Component SSR for Radiant adapter helpers (renderComponent, install runtime, light-DOM policy).

Server Rendering Rules

  • on:* and on-native:* bindings are skipped in raw HTML output
  • prop:* bindings are applied as properties on server-renderable custom element instances
  • plain text content is escaped
  • iterables are serialized by walking their children in order
  • subscribable and signal-like values are snapshotted to their current value during SSR

Async SSR Pattern

Async work belongs before the render pass starts, not inside it.

  • fetch data, resolve assets, or build view models before calling the SSR helper
  • pass resolved values into the component or controller before renderToString(...) runs
  • keep render() synchronous and treat SSR as a snapshot of current state

For example, this is the intended shape:

const product = await api.getProduct(productId);
 
const rendered = await renderController(ProductController, {
	tagName: 'section',
	initialize: (controller) => {
		controller.product = product;
	},
});

By contrast, fetching inside render() is not part of the current SSR model.

Orchestration around SSR may still be async on Node. For example, a route handler can await data loading, then call renderToString(...) inside withActiveSsrScopeValue(...). Keep component render() synchronous; treat SSR as a snapshot of resolved state.

SSR Render Scope

renderToString(...) runs inside an active SSR render scope. @ecopages/jsx/server is Node-only and stores that scope in AsyncLocalStorage, so concurrent requests and abandoned async work do not leak state across unrelated renders.

Most app code never touches scope directly. Framework adapters use it to carry shared server state across nested renderToString(...) calls or split entrypoints.

When to use scope helpers

NeedAPI
Share hydrate binding indexes across sibling renderToString(...) callscreateServerHydrationBindingState() + withServerHydrationBindingState(...)
Share other framework-owned SSR state (asset frames, runtime bridges, etc.)withActiveSsrScopeValue(...) + getActiveSsrScopeValue(...)

Prefer Symbol.for('@your-package.namespace') keys so scope values survive entrypoint boundaries. Await I/O outside the scope when you only need scoped state during a synchronous render snapshot.

Example: sibling renders in one hydration root

/** @jsxImportSource @ecopages/jsx */
 
import { jsx } from '@ecopages/jsx/jsx-runtime';
import { renderToString, withActiveSsrScopeValue } from '@ecopages/jsx/server';
 
const SHARED_SCOPE_KEY = Symbol.for('@acme/ssr.shared-root');
 
const html = withActiveSsrScopeValue(SHARED_SCOPE_KEY, { started: true }, () => {
	const page = renderToString(jsx('section', { class: 'page', children: 'Page' }), { mode: 'hydrate' });
	const shell = renderToString(jsx('main', { class: 'layout', children: page }), { mode: 'hydrate' });
	return shell;
});

Hydration binding indexes continue across both renderToString(...) calls because they share one active SSR scope.

Mental Model

The important distinction is:

  • SSR is HTML generation
  • hydration is the later client attach step

They are related, but not the same operation.

Related APIs

  • JSX Client Rendering for createRoot(...), hydrate(...), and hasHydrationMarkers(...)
  • RadiantElement for host-owned render lifecycle
  • Component SSR for renderComponent(...) and the server pipeline
  • Hydration for the current explicit client hydrator contract used by render-owning RadiantElement SSR pages