# Client interactivity

> Single-script routes, SSR data hydration, and the `on<event>={fn(args)}` directive

Package: Koze
Canonical: https://kuratchi.dev/docs/koze/client-interactivity
Markdown: https://kuratchi.dev/docs/koze/client-interactivity.md

Koze routes render on the server, but the authored top `<script>`
model is client-first. There is one main script to write, one mental
model to hold, and one file to edit when you want to add browser
behavior.

## Single-script routes

A route's top `<script>` block is authored as **client code with explicit
server escape hatches**. The framework server-runs the parts needed for
SSR, serializes awaited results into the HTML response, and hydrates
those values into the browser copy of the script so references like
`boardViews` below resolve immediately on first paint.

```html
<script>
  import { listBoardViews, getBoard } from '$server/board';
  import { searchParams } from 'koze:request';

  const slug = searchParams.get('board') || 'default';
  const boardViews = await listBoardViews();
  const board = await getBoard(slug);

  function badgeClass(priority) {
    return priority === 'high' ? 'badge-red' : 'badge-gray';
  }

  function openPrompt(slug) {
    const title = window.prompt('Title?');
    if (title) location.assign(`/board/${slug}/new?title=${encodeURIComponent(title)}`);
  }
</script>

<h1>{board.name}</h1>
<ul>
  for (const item of board.items) {
    <li class={badgeClass(item.priority)}>{item.title}</li>
  }
</ul>

<button onclick={openPrompt(board.slug)}>Add item</button>
```

> **Note:**
You author **one** top `<script>` block per route. Add a second
`<script>` block and the compiler reports an error. If a route script
gets large, move helpers into `$lib/*` modules and import them from the
top script.

## Reactive UI

Use `$:` for client reactivity. Koze keeps ordinary JavaScript
ordinary until a top-level `let` is read by `$:`, `bind:value`, or a live
template expression.

```html
<script>
  const allCells = await listCells();

  let selectedLocationId = '';
  let selectedCellId = '';

  $: selectOptions = allCells.filter(
    cell => cell.locationId === selectedLocationId,
  );

  $: if (!selectOptions.some(cell => cell.id === selectedCellId)) {
    selectedCellId = '';
  }
</script>

<select bind:value={selectedLocationId}>
  <option value="">Choose...</option>
  for (const location of locations) {
    <option value={location.id}>{location.name}</option>
  }
</select>

<select bind:value={selectedCellId}>
  <option value="">Choose...</option>
  for (const cell of selectOptions) {
    <option value={cell.id}>{cell.name}</option>
  }
</select>
```

Reactive reads update these template surfaces in the browser after the
server-rendered first paint:

- text expressions like `{selectOptions.length}`
- normal attributes like `hidden={...}`, `class={...}`, and `value={...}`
- `if (...) { ... } else { ... }` blocks
- `for (const item of items) { ... }` blocks
- `bind:value={form.field}` form controls

`bind:value` is only the DOM write-back API. `$:` is still where derived
state and effects live.

### Declaring reactive state

Top-level `let` bindings become reactive state when they are read by
`$:`, `bind:value`, or a live template expression. Use them for mutable
source values:

```html
<script>
  let search = '';
  let selectedLocationId = '';
</script>
```

Derived values can be declared directly with `$:`. You do not need to
predeclare a placeholder `let` first:

```html
<script>
  const allCells = await listCells();

  let selectedLocationId = '';

  $: filteredCells = allCells.filter(
    cell => cell.locationId === selectedLocationId,
  );
  $: hasCells = filteredCells.length > 0;
</script>

<select bind:value={selectedLocationId}>...</select>

if (hasCells) {
  for (const cell of filteredCells) {
    <p>{cell.name}</p>
  }
}
```

In that example `filteredCells` and `hasCells` are both defined by their
`$:` assignments and exposed to live template blocks. Top-level `const`
bindings, like `allCells`, stay readonly; they can be read by reactive
state but are not converted into mutable state themselves.

Inside template loops, use loop locals directly. Koze carries those
locals into client bindings, so `bind:value={forms[item.id].selected}`
works without DOM traversal.

## How hydration works

At render time the framework:

1. Runs the top `<script>` on the server. `await $server/*` calls resolve
   against real modules; `koze:request` / `koze:environment`
   imports resolve against the live request.
2. Captures every top-level binding that was populated by an `await`
   (direct or ternary) and every top-level `let` that was mutated inside
   an `await`-bearing block.
3. Serializes those values into an inline `<script type="application/json"
   id="__koze_data">` element.
4. Emits a `<script type="module">` for the same top-script code,
   rewritten so that `const X = await fn()` reads become
   `const X = __kozeReadData("X")`.

In the browser the client copy of the top script runs with those hydrate
reads already wired up — **no extra network round-trips for data that
was already computed server-side**. Helper functions, `$:` reactivity,
and synchronous bindings stay available in that browser copy.

```html
<!-- Rendered HTML, abbreviated -->
<script type="application/json" id="__koze_data">
  {"boardViews":[...],"board":{...},"__params":{}}
</script>
<script type="module" src="/assets/koze-client-<hash>.js"></script>
```

The JSON blob is **data**, not code — browsers never parse it as
JavaScript — so the payload can safely carry arbitrary strings,
including values that contain `<script>` or `</script>` fragments.

### What gets hydrated

| Pattern | Hydrated? | Notes |
| --- | --- | --- |
| `const x = await fn()` | Yes | Direct top-level await. |
| `const x = cond ? await fn() : other` | Yes | Ternary with an `await` in either branch. |
| `let x = null;` + `if (...) { x = await fn(); }` | Yes | `let` bindings reassigned inside an `await`-bearing top-level block. |
| `const x = computeSync()` | No | Pure synchronous code re-runs in the browser. |
| `function helper() { ... }` | No | Declarations run on both sides. |
| Anything inside a function body | No | Nested `await` only fires when the function is called. |

### Stripping SSR-only code from the browser bundle

The framework strips every top-level statement whose body contains an
`await` from the browser bundle — those blocks already ran server-side
and their mutations are captured in the hydrate payload. Your event
handlers, helper functions, non-async conditionals, and template
expressions are preserved.

### Browser RPC after hydration

When a `$server/*` import is called in the browser, the generated stub
returns the same thenable async-value contract the server uses for
non-awaited calls:

```html
<script>
  import { refreshBoard } from '$server/board';

  async function onRefresh() {
    const result = refreshBoard();

    if (result.pending) {
      console.log('Refreshing…');
    }

    await result;
  }
</script>
```

That means you can either `await refreshBoard()` for blocking flow or
inspect `.pending`, `.error`, and `.success` immediately for reactive UI.

### Reading additional SSR values

Sometimes you want SSR to compute a value the template doesn't need but
a client handler does. Use `__kozeReadData`, the runtime helper the
framework injects when hydration is in play:

```html
<script>
  import { listUsers } from '$server/users';

  const users = await listUsers();
  const now = Date.now();

  function greet() {
    // `users` is hydrated (from `await`). `now` is not — it re-runs
    // client-side. Read any extra SSR value explicitly by key:
    const firstName = __kozeReadData('users')[0]?.name;
    alert(`Hi ${firstName}, current time ${now}`);
  }
</script>

<button onclick={greet()}>Greet</button>
```

## Event handler directive

Author DOM handlers declaratively with `on<event>={expr}`:

```html
<button onclick={archiveItem(item.id)} type="button">Archive</button>
<input onchange={updateFilter(filter.key)} type="text" />
<form onsubmit={submitDraft()}>...</form>
<dialog onkeydown={handleDialogKey()}>...</dialog>
```

Supported events: `click`, `change`, `input`, `submit`, `keydown`,
`keyup`, `focus`, `blur`.

The `expr` between `{…}` must be a call expression:

```html
<!-- ✅ Supported -->
<button onclick={save()}>Save</button>
<button onclick={save(item.id)}>Save</button>
<button onclick={save(item.id, 'x')}>Save</button>
<button onclick={helpers.save(item.id)}>Save</button>

<!-- ❌ Not supported — arrow bodies, statements, bare identifiers -->
<button onclick={() => save(item.id)}>Save</button>
<button onclick={save}>Save</button>
<button onclick={save(); track()}>Save</button>
```

### Dispatch rules

The framework picks the dispatch path based on what the callee is:

| Callee shape | Dispatch | What runs |
| --- | --- | --- |
| `$server/*` import (e.g. `deleteItem` imported from `$server/items`) | **Server action** — POSTs to the route URL | The route's `actions[fnName]` server function, invoked as `fn(...args, ctx)` where `ctx` is `{ formData, request, url, params }`. See [Actions](/docs/koze/actions) for the full signature. |
| Function declared in the top `<script>` OR `$lib/*` import | **Client handler** — runs in the browser | The function, with the args evaluated at SSR time. |
| Anything else (bare expression) | **Native inline** — legacy escape hatch | The literal expression in an inline `onclick=`. Requires CSP `unsafe-inline`. |

The args (`item.id`, `filter.key`, …) are **evaluated at SSR time** — they
are plain serialized values by the time the browser sees them. To access
the DOM event, receive it as a trailing parameter in the handler
function:

```html
<script>
  function handleDialogKey(event) {
    if (event.key === 'Escape') event.currentTarget.close();
  }
</script>

<dialog onkeydown={handleDialogKey()}>...</dialog>
```

The bridge passes `(…args, event, element)` to every handler, so an
author-declared function with fewer parameters just ignores the
trailing ones.

### Client handler vs. direct server-action dispatch

Both paths work. Pick based on what the interaction needs:

```html
<!-- Direct server-action dispatch: one line. Framework POSTs, the
     browser re-renders on success. Use for fire-and-refresh clicks
     where you don't need UI between the click and the response. -->
<button onclick={deleteItem(item.id)}>Delete</button>

<!-- Client handler wrapper: use when you need confirmation, optimistic
     updates, inline error display, or anything between the click and
     the network round-trip. `$server/*` imports are real functions in
     the browser bundle — the framework auto-generates an RPC stub —
     so calling `await deleteItem(id)` works inside the handler. -->
<script>
  import { deleteItem } from '$server/items';

  async function onDelete(id) {
    if (!confirm('Delete?')) return;
    await deleteItem(id);
    location.reload();
  }
</script>

<button onclick={onDelete(item.id)}>Delete</button>
```

The server side is the same function in both cases. See
[Actions](/docs/koze/actions#button-triggered-actions) for the server
function signature.

## Client bridge

When a route uses `on<event>={…}`, the framework injects a ~1 kB bridge
into the route's client bundle. The bridge:

- Registers a single document-level listener per event type.
- Looks up the ancestor carrying `data-client-*` attributes.
- Dispatches to the handler registered via
  `window.__kozeClient.register(routeId, {…})` for client handlers.
- POSTs `_action` / `_args` to the route URL for server-action
  callees (`$server/*` imports).

Handler expressions are stored in a per-route table keyed by a short
id (`h0`, `h1`, …) and referenced from the HTML via
`data-client-handler="h0"`. Handler bodies live in the client bundle —
`data-client-args="[1]"` only carries the serialized args.

The bridge is idempotent: multiple registrations against the same
`routeId` merge their handler tables, so HMR and client-side
navigations that recycle the document don't double-bind.

## Security

- The JSON hydration payload escapes `<` to `\u003c` so an attacker
  can't close the `<script>` tag early with user-supplied data.
- Handler ids are validated against a strict identifier regex and
  prototype-pollution names (`__proto__`, `constructor`, `prototype`)
  are blocked at both register and invoke time.
- Server-action POSTs are same-origin and include the action name from
  a trusted compile-time attribute, not a user-controlled identifier.
- Native inline `onclick="expr"` (the legacy escape hatch) requires a
  CSP that allows `unsafe-inline`. The declarative
  `onclick={expr}` directive does not — the bridge is a normal
  same-origin module script.

## Browser-only DOM code

Route files allow one `<script>` block: the top script. That script is
authored client-first, but it must still be safe for SSR analysis and
initial render. Keep direct DOM access inside event handlers or lazily
called functions, not in top-level module execution:

```html
<script>
  import { getItems } from '$server/items';
  import { mountWidget } from '$lib/widget';

  const items = await getItems();

  function activateWidget() {
    mountWidget(document.querySelector('[data-widget]'));
  }
</script>

<div data-widget>...</div>
<button onclick={activateWidget()}>Activate widget</button>
```

Modules imported from `$lib/*` should be SSR-safe at import time. If a
third-party browser library touches `window` during import, wrap it in a
small `$lib/*` helper that loads it lazily inside the browser-only
function.
