{#if}, {#each} and their {:else}/{:else if}/{:empty} branches worked on
the server and after hydration, but a block nested inside another block
stayed empty once the outer block rerendered. Adding a row to a list
produced the row's markup with its inner block markers in place and
nothing between them, for the life of the page.
Two causes, both on the client-created path only:
reactive() registers an effect; effects run when renderAll sweeps the
list. A state change runs just the affected effects rather than sweeping,
so an effect registered during that rerender was queued and never
invoked. setupControlBlock now returns its runner and the creating block
invokes it immediately.
The first reactive pass is skipped so hydration does not discard
server-rendered DOM. A block created by a rerender has no server DOM, so
skipping its only pass left it permanently empty. firstRun is now keyed
off outerLocals, which is set only on the client-created path.
Verified in a browser as well as in tests: adding a group to a list now
renders the new row's nested {:else}, and the existing rows' nested loops
survive the rerender.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@wrnexus/csr
Navigation state preservation
Pages can opt into restoration across client navigation:
page Users {
navigation {
preserve = ["filters", "pagination", "scroll", "tabs", "expanded"]
}
}
Form-like categories restore named inputs, selects, and textareas. Password,
file, hidden, CSRF/token/secret/credential fields, and elements marked
data-no-preserve are never saved. For tab, expanded, or component UI state,
mark stable elements with data-wrn-preserve="key"; their value and ARIA
selected/expanded state are restored. State is scoped to pathname plus query.
Typed server actions
createActionClient<Input, Output>(route, name) supports programmatic calls.
Schema-backed WRN actions also export __wrnexusActionClients, whose input and
output are inferred automatically. Enhanced forms expose
data-wrn-action-state="pending|success|error" and dispatch bubbling
wrnexus:action:optimistic, :pending, :success, and :error events.
Success details contain returned data and invalidated cache tags; error details
contain field errors. Without JavaScript, the same form posts to its page and
receives a 303 redirect or accessible validation response.
The browser-side client runtime for WrNexus — generic, self-contained JS that hydrates server-rendered pages with reactivity, client-side navigation, and realtime rooms.
Part of the WrNexus framework — an SSR-first, Bun-native full-stack web framework.
Overview
@wrnexus/csr holds the three client runtimes that WrNexus serves to the browser. Components are authored as .wrn files and rendered on the server; this package provides the single, generic runtime that hydrates that HTML in the browser — there are no per-component browser bundles. Each runtime is exported as a plain-JS string (no build step, no imports) intended to be served verbatim from a well-known URL:
- reactive at
/__wrnexus/reactive.js— reactive directives (data-scope,data-text,data-for, …) - nav at
/__wrnexus/nav.js— SPA-style client navigation with graceful fallback - realtime at
/__wrnexus/realtime.js— WebSocket "rooms", declarative or programmatic
The package itself runs on the server (it just returns strings); the strings it returns run in the browser. A dev/prod server (see @wrnexus/core) is responsible for actually serving them.
Installation
bun add @wrnexus/csr
Private package — the machine must be authenticated to the
wrnexusnpm org (a read token in~/.npmrc). Requires Bun (Node is not supported).
API
All exports come from the package root (@wrnexus/csr). The runtime source is delivered as strings, so the "API" on the server side is small; the real surface is the browser directives/globals each string installs.
Runtime strings
| Export | Type | Served at | Contents |
|---|---|---|---|
REACTIVE_RUNTIME |
string |
/__wrnexus/reactive.js |
Reactive directive runtime |
NAV_RUNTIME |
string |
/__wrnexus/nav.js |
Client-side navigation runtime |
REALTIME_RUNTIME |
string |
/__wrnexus/realtime.js |
Realtime rooms runtime |
Accessor functions
Convenience getters that return the same strings.
getReactiveRuntime(): string // → REACTIVE_RUNTIME
getNavRuntime(): string // → NAV_RUNTIME
getRealtimeRuntime(): string // → REALTIME_RUNTIME
Browser: reactive directives
Applied to any subtree containing data-scope. Expressions are parsed by a tiny eval-free evaluator, so a strict CSP with no unsafe-eval works.
| Directive | Purpose |
|---|---|
data-scope="count: 0, name: 'x'" |
Declare reactive state on a subtree |
data-on-<event>="count++" |
Run a statement in scope on a DOM event |
data-text="expr" |
Bind an element's textContent to an expression |
data-show="expr" |
Toggle visibility while preserving interactive state |
Compiled conditional rendering and dynamic component cases omit inactive elements from the live
DOM. data-show is a visibility directive for stateful controls and keeps its element mounted.
Neither mechanism is authorization: never place secrets in client-rendered branches. Authorize on
the server and return only data the current request may access.
| data-for="item in list" (opt. index and key item.id) | Per-item rendering; stable keys preserve DOM identity during reorder |
| data-key="item.id" | Alternative key declaration for data-for templates |
| {{expr}} or {expr} | Interpolation inside text nodes and attribute values |
| data-wrnexus-csr="id" | Target for a generated CSR fetch binding (fetches /__wrnexus/csr?...) |
Supported expression features: literals, identifiers, member access (a.b, a[b]), function/method calls, arrays, objects, arithmetic, comparison, equality, logical (&& ||), unary (! - +), and ternary. Statements support ++/--, assignment operators (= += -= *= /= %=), and bare expression/method calls. Rendering is dependency-tracked: a signal change only re-runs the renderers that actually read it.
Browser globals installed: window.__wrnexusHydrateScopes(root) and window.__wrnexusHydrateCsrFetches(root) — both idempotent, so re-running after a DOM swap or HMR morph is safe. Both run automatically on DOMContentLoaded.
Browser: navigation
Intercepts same-origin <a> clicks, fetches the target page, and swaps the #app container in place (via importNode — not innerHTML — so it works under a Trusted-Types CSP), updating history, title, and scroll, then re-hydrates. Cross-origin links, modified clicks, download/data-no-nav/rel="external"/target links, non-HTML responses, or a missing #app fall back to a full browser navigation.
- Programmatic navigation:
window.__wrnexusNavigate(url) - Emits a
wrnexus:navigatedCustomEvent(detail.url) after each swap - Sends
x-wrnexus-nav: 1on fetches so the server can return the page fragment - Appends any
/__wrnexus/*runtime scripts the incoming page needs but the current document lacks
Browser: realtime rooms
Connects to /realtime/<name> over WebSocket (ws/wss chosen from location.protocol). Two usage modes.
Programmatic API via window.wrn:
wrn.room(name): Room // open (or reuse) a room connection
wrn.bindRooms(root?) // (re)bind declarative [data-room] containers
interface Room {
name: string;
send(obj: object | string): Room; // JSON-stringifies objects; queues until open
on(type: string, cb): Room; // filter by msg.type; "*" or a fn = all messages
on(cb): Room;
close(): Room;
}
Internal lifecycle messages are emitted to listeners as { type }: __open, __close, __error, and __raw (non-JSON frames, with data). Reconnect uses exponential backoff capped at 5s; queued sends flush on reconnect.
Declarative binding (zero JS) on a data-room="<name>" container:
| Attribute | On | Purpose |
|---|---|---|
data-room="<name>" |
container | Connect to room <name> |
data-room-user="<id>" |
container | Identify the connection (?user=<id>) |
data-room-log |
element | Where incoming messages are appended |
<template data-room-item="<type>"> |
template | Row template for messages of that type (empty = fallback) |
%field% |
inside template | Placeholder filled from the message field (text/attr only, HTML-escaped) |
data-room-status |
element | Reflects connection state text (connected/disconnected/error) |
data-room-status-class |
status element | Base class; a state variant (is-connected, …) is appended |
<form data-room-send> |
form | Submits named fields as a JSON message |
data-room-reset |
form field | Clears that field after send |
Rebinds on wrnexus:navigated and closes rooms whose container has left the page.
Usage
Server side — serve the runtime strings from your router (example with Bun.serve):
import { getReactiveRuntime, getNavRuntime, getRealtimeRuntime } from "@wrnexus/csr";
const routes: Record<string, string> = {
"/__wrnexus/reactive.js": getReactiveRuntime(),
"/__wrnexus/nav.js": getNavRuntime(),
"/__wrnexus/realtime.js": getRealtimeRuntime(),
};
Bun.serve({
fetch(req) {
const body = routes[new URL(req.url).pathname];
if (body) {
return new Response(body, {
headers: { "content-type": "text/javascript; charset=utf-8" },
});
}
return new Response("Not found", { status: 404 });
},
});
Browser side — server-rendered HTML that the reactive runtime hydrates:
<div data-scope="count: 0, showPassword: false">
<button data-on-click="count++">+1</button>
<span data-text="count"></span>
<p>Total: {{count}}</p>
<input type="{showPassword ? 'text' : 'password'}" />
<button
data-on-click="showPassword = !showPassword"
aria-label="{showPassword ? 'Hide password' : 'Show password'}"
>
Toggle password
</button>
</div>
<script src="/__wrnexus/reactive.js"></script>
State interpolation in ordinary attributes is reactive. The compiler keeps the
initial SSR value and emits an internal binding so attributes such as type,
aria-label, aria-pressed, class, and href update after state changes.
A realtime chat, fully declarative:
<div data-room="lobby" data-room-user="ada">
<div data-room-status></div>
<ul data-room-log></ul>
<template data-room-item="chat"><li>%user%: %text%</li></template>
<form data-room-send>
<input name="text" data-room-reset />
<input type="hidden" name="type" value="chat" />
<button>Send</button>
</form>
</div>
<script src="/__wrnexus/realtime.js"></script>
Or drive a room from code:
const room = wrn.room("lobby");
room.on("chat", (msg) => console.log(msg.user, msg.text));
room.send({ type: "chat", user: "ada", text: "hi" });
Requirements / Notes
- Bun-only on the server (the package integrates with Bun-based WrNexus servers); the emitted strings are plain browser JS with no dependencies.
- Browser runtimes are self-contained (no imports, no build step) and idempotent, so re-hydration after navigation or HMR is safe.
- Designed for a strict CSP: the reactive expression evaluator avoids
eval/new Function(nounsafe-eval), and DOM swaps useimportNode/attribute writes rather thaninnerHTML(Trusted-Types friendly). - Peer packages: rendered
.wrncomponents and the serving layer come from@wrnexus/core(the sole dependency); pages are rendered by the WrNexus dev/prod server.