@zerotal/flow 1.0.3 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,13 +1,42 @@
1
- # Changelog — @zerotal/flow
1
+ # Changelog — @zerotal/flow
2
2
 
3
3
  All notable changes to this package are documented here. The format is
4
4
  based on [Keep a Changelog](https://keepachangelog.com/); this package
5
5
  follows the Zerotal monorepo's unified versioning.
6
6
 
7
- **Maturity: `stable`**
7
+ **Maturity: `experimental`** — matching this package's `maturity` field. Flow shares the
8
+ monorepo's version line but not the stable packages' API guarantees; it may change
9
+ between minor versions.
8
10
 
9
11
  ## [Unreleased]
10
12
 
13
+ ## [1.1.0] — 2026-08-08
14
+
15
+ ### Fixed
16
+
17
+ - **A radio group can be bound again.** `_readModelValue` special-cased checkboxes but not radios, so every radio in a group contributed its `value` whether or not it was checked and the last one in DOM order won — a leisure enquiry silently became a corporate one, with no error anywhere. A radio now reports a value only when checked, and the flush, `input` and `flow:model.blur` paths all skip the `undefined` an unchecked radio returns. The blur path was additionally reading `.value` raw, so it sent `"on"` for checkboxes. Server-to-client sync no longer assigns `el.value` on a radio either, which was rewriting every option in a group to the group's current value and destroying the choices.
18
+ - **A reactive sibling attribute no longer suppresses a `value` binding.** The getter capture is a single slot and JSX evaluates every prop before `jsx()` runs, so `value={this.destination} disabled={this.notSure}` left the capture on `notSure`, the freshness check failed, and the element rendered with no `flow:model` at all — the field accepted typing and nothing reached the server. The capture now retains the element's reads, so a binding finds the key matching its own value. Only keys genuinely read through a `this.` getter qualify, so a literal that happens to equal state still binds nothing.
19
+ - **A radio no longer acquires a binding by coincidence.** Value-identity inference matched whichever single option currently equalled the bound property, so that one radio claimed the whole group's model. Radios are now excluded from binding inference, as `<option>` already was; bind a group explicitly with `{...this.bind('type', optionValue)}`.
20
+ - **`flow:click` no longer breaks the control it sits on.** `preventDefault()` was unconditional, which cancels the element's activation behaviour — a radio or checkbox carrying `onClick` never became checked, and no handler could compensate, because the browser restores the pre-click checkedness after listeners run. The default is now cancelled only where it is in the way (anchors, submit buttons and submit inputs), with `.prevent` to force it and `.passive` to opt out.
21
+ - **Click handlers carrying a modifier are no longer dead.** Modifiers are emitted into the attribute name (`flow:click.stop`), but the listener looked handlers up with an exact attribute selector — which cannot match that name — so `onClick={flow(this.save).stop}` did nothing at all, silently. Handler lookup now reads both forms and applies `.stop`.
22
+ - **`request().ip()` answers inside an action.** The WebSocket dispatcher builds its own `HttpContext`, which has no Bun `Server` behind it, so `ip()` resolved to `null` for every action even though the peer address was captured at upgrade. It is now supplied.
23
+
24
+ ### Added
25
+
26
+ - **A server action can take arguments from a loop.** `() => this.archive(row.id)` compiles to `flow:click="archive"` plus `data-args`, with the arguments evaluated server-side during the render where `row` exists. Previously the arrow was emitted verbatim, so `row` was undefined in the browser and the click threw a `ReferenceError` into the console and nothing else. An argument that reads `this` still stays a live client expression so it re-evaluates against reactive state.
27
+ - `Component.bind(key, optionValue)` — the explicit way to bind one member of a radio group.
28
+ - `FlowTest.tolerateErrors()`, `assertErrored()`, `lastError()`, `seed()` and `render()`.
29
+
30
+ ### Changed
31
+
32
+ - The members `Component` reserves are documented, and pinned by a test so the list cannot drift. A page property colliding with one is a compile error with a clear message, but `title` — taken by the page-title accessor — is an obvious name for a field on a row representing a media item or a review, and there was no list to check.
33
+ - **`FlowTest.call()` rethrows.** A `ValidationError` is still an expected outcome, but any other error is routed to `onError()` (as in production) and then rethrown, so a broken action fails its test instead of looking like one that ran and did nothing. Previously it was swallowed — and a throwing `onError` was swallowed too. Call `tolerateErrors()` when the error path is what you are testing.
34
+ - **`FlowTest.set()` re-renders.** A test that set a property and then read `html()` was silently asserting against the previous render. `seed()` is the batching form that does not render.
35
+ - **A handler pointing at an un-`@expose`d method is a build error.** The allowlist is the exposed methods, so an undecorated method was absent from it — the compiler emitted `flow:click="submit"`, the button rendered enabled, and the click sent a frame the server refused, reporting the refusal only over the WebSocket to `console.error`. Nothing reached the server log and the page did not change. The member table needed to catch this was already built; it is now used.
36
+ - **A client expression referencing a name the browser will not have is reported at boot**, naming the identifier and pointing at `data-args`. The page falls back to the runtime renderer rather than failing the build: the check works from a hand-maintained list of globals, and a false positive should not stop a server starting. In CSP-safe mode, where there is no runtime fallback, it is fatal.
37
+ - The compiler warns when more than half of a page set falls back to the runtime renderer, naming `ZT_FLOW_COMPILE_LOG=1`. Falling back for a page or two is normal; an entire app doing it silently is not.
38
+ - Maturity is stated in the README and npm metadata. It was only ever a `package.json` field npm does not render, and this changelog said `stable` while the manifest said `experimental`.
39
+
11
40
  ## [1.0.3] — 2026-08-07
12
41
 
13
42
  ### Changed
@@ -27,40 +56,40 @@ _First public release._
27
56
 
28
57
  ### Added
29
58
 
30
- - **`make:flow` scaffolding command.** The flagship server-driven layer previously had no generator — every `Component` was hand-written (the audit's #1 DX gap). `bun zt make:flow Name` emits a ready-to-run class with the JSX pragma, imports, and an `@expose`/`render` skeleton: `--child` scaffolds a child component (with `setup(props)`), `--crud` a resourceful page (list + create/edit/delete + `@validate` + a form), and `--layout Foo` wraps a page in a layout (with the correct relative import depth for nested names like `Users/Index`). The target directory is auto-detected (`app/flow/pages` for pages, `app/flow/components` for children) or set with `--dir`; since Flow uses file-based routing the page is served automatically, and the command also prints the explicit `Router.flow(...)` line. Registered lazily by `FlowProvider` in console mode, so it's available in any app that registers the provider.
31
- - **HTTP fallback for WebSocket-blocked networks (Tier 1).** A client circuit breaker: after `HTTP_FALLBACK_AFTER` (3) failed socket handshakes the bridge stops waiting on the WebSocket and sends action frames as plain `POST /__flow/http` requests, which run the _exact same_ server pipeline (hydrate → dispatch → render → patch) over a collecting fake connection and return the frames as a JSON array the client applies — so actions, validation, flashes, redirects, events, and offline-queue replay all keep working behind strict proxies/firewalls that drop WS upgrades, with no component change. It's a graceful degrade, not a configured mode: WS reconnection keeps running and the client upgrades back automatically the moment the socket succeeds (`onopen` resets the failure count and exits HTTP mode). `this.cancel()` POSTs an out-of-band `$cancel` in HTTP mode too. Trade-offs while degraded: no server-pushed frames (so `@task` arrives as one batched update, not token-by-token) and no broadcast-driven `@presence`/`@shared`/`@on("echo:…")` (those ride the separate broadcasting socket); everything driven by the user's own actions still works. Nothing is sent over HTTP until the socket has actually failed.
32
- - **Reactive lists (`<For>`) + optimistic collections.** `<For each={this.items} keyBy="id">{(item) => …}</For>` compiles to an Alpine `<template x-for>`, so any client change to the array re-renders the list instantly (server patches still keep it authoritative) — the missing piece that made optimistic list ops impossible while `.map()` renders to static HTML. The item template supports element structure, static attrs, `class`/`className` (→ `:class`), reactive attrs, `on*` arrow handlers (→ Alpine `@event`, `this.`→`$flow.`), and `{item.field}` text (→ `x-text`); anything else is a clear compile error pointing at a raw `x-for` escape hatch. On top of it, `this.appendOptimistic(prop, item)` / `this.removeOptimistic(prop, match)` mutate the array before the server confirms: the change shows at once, **survives interim (broadcast/event) patches** (pending ops are re-applied over each server merge), and is **reconciled when the owning action's patch resolves** — server list on success, automatic rollback on failure. Pure reconciliation core in `client/optimistic.ts`; new `For`/`ForProps` exports.
33
- - **Dev error overlay.** Under `serve --dev`, an unexpected throw — in a server action **or** the initial `GET` render (`onMount`/`render`) — pops a full-screen overlay (Vite/Next style) with the error class, message, and stack, naming the action + component and dimming framework/`node_modules` frames so app frames stand out. Dismiss with Esc or a backdrop click; after an action error the patch still reconciles underneath, so you return to a live page (pairs with the time-travel timeline). The detail is attached **only under the dev worker** — production sends none (an action flashes; an initial-render error returns a normal 500). Only unexpected throws overlay: validation errors and **intended HTTP errors** (auth `401`/`403`, `404`, redirects — anything carrying a numeric `status`) pass straight through to their normal handling. New module `client/errorOverlay.ts`; the error frame gained optional dev-only `name`/`stack`/`action` fields; the initial-render error rides an embedded `flow-boot-error` script the bridge reads on load.
34
- - **Type-safe real-time events (Tier 1).** An app-augmentable `FlowEvents` contract maps event names to payload types; `dispatch`/`dispatchTo`/`dispatchSelf` are now generic over it, so a known event's payload is type-checked at every dispatch site (server actions and client expressions alike) — wrong shape, missing payload, or a payload on a `void` event all fail to compile — while unknown names stay untyped (a `string` fallback keeps `echo:…` and gradual adoption compiling, no breakage). `@on` autocompletes to known names; annotate handlers with `EventPayload<K>`. No codegen. An optional runtime guard (`registerFlowEvent(name, guard)`) throws from `dispatch` on a malformed payload — for the untrusted client-originated path the types can't cover. New module `events.ts`; exports `registerFlowEvent`, `FlowEvents`, `EventName`, `EventPayload`.
35
-
36
- - **`@presence` — multiplayer "who's here" (Tier 1).** Binds a property to a broadcast presence channel (static or a `(self) => string` resolver) and keeps it filled with the live member list, refreshed on join/leave via a new `$presence` built-in. The resolved channel is server-derived and carried signed in the snapshot; the prop is server-controlled (locked). Client-only cursor/typing whispers ride the same channel via `this.whisper(event, data)` / `this.onWhisper(event, cb)`. Broadcasting is an optional peer — resolved through a dynamic import, a no-op when absent — so flow takes no new hard dependency.
37
- - **Time-travel devtools (Tier 1).** Under `serve --dev`, the client records the signed snapshot stream it already receives — one frame per applied patch plus the initial mount — into a capped ring buffer, and can scrub back to any frame: a jump re-applies that frame's snapshot + HTML to the live component (client-only, no server round-trip), restoring state and DOM exactly. It surfaces as a **Timeline tab in the `@zerotal/devtools` panel** (registered via that package's new `window.__zerotalDevtools` extension API) so all the framework's tooling is unified; when devtools isn't present it falls back to a standalone ⏱ panel. Frames list by action + changed fields + time with click-to-jump and a ⏵ Resume-live button; a `window.__flow.timeline` console API (`frames`/`framesFor`/`jump`/`live`) mirrors it. Dev-gated via the `dev` flag on the WS `ready` frame — production ships none of it. The recording core is a DOM-free module (`client/timeline.ts`); the DOM apply step is injected from the bridge, and the patch morph was extracted to a shared `_morphComponent` reused by both a patch and a jump.
38
- - **Durable / resumable snapshots (Tier 1).** Opt a component in with `static durable = true` (or `{ ttl, scope }`) and its signed snapshot is persisted server-side after every request, keyed by user (or session) + route. On a fresh GET a valid stored snapshot is restored and `onHydrate()` runs instead of `onMount()`, so an in-progress flow resumes exactly across a full reload, tab close/reopen, device switch, or reconnect after the held client snapshot is gone — nothing stored in the browser. HMAC verification + identity keying isolate users and reject tampered/rotated-key entries (graceful fresh mount). `this.clearDurable()` forgets the entry on flow completion. The store is pluggable via `setDurableStore()` (default in-process TTL store; swap a `@zerotal/cache`/Redis-backed `{ get, set, delete }` for redeploy survival). `scope: "user"` keys by the authenticated user (session fallback when anonymous); `scope: "session"` keys per-device.
39
- - **`@task` — streaming, cancellable actions (Tier 1).** Mark an async method `@task` (implicitly `@expose`) and write a field in a loop — while the action runs the framework flushes throttled **field-level** snapshot diffs (the changed fields only, no HTML, no per-chunk re-render), so a reactive binding of the field (`text={this.answer}`, `x-text`, a reactive `:attr`) streams to the browser live as `this.answer += token` runs. The field stays the source of truth (no `flow:stream` element, no double-write); the final patch re-renders once to reconcile any static template positions. The triggering control keeps its loading state for the whole run (partial patches don't clear it; only the final patch does). `this.signal` (a standard `AbortSignal`) + `this.cancelled` give cooperative cancellation; the client's `this.cancel()` sends an out-of-band `$cancel` frame (bypassing the per-component send queue the running task occupies), which the server handles before dispatch to abort the task's controller. Implemented via a `partial` patch flag and a chained delta base (each streaming patch is delta-encoded against the previous one the client received).
40
- - **`@shared` — convergent multiplayer state (Tier 1).** Binds a property to server-authoritative shared state on a channel: mutating it in an action writes to a per-channel **room store** and broadcasts to the channel, so every other subscriber re-reads and converges via a new `$shared` built-in. Before each action the prop is refilled from the store (read-latest); after, changed props are written back and broadcast (`toOthers`). The resolved channel is signed in the snapshot (`SnapshotMemo.shared`) and the prop is server-controlled (locked). The store is pluggable via `setSharedStore()` (default in-process; swap a Redis-backed `{ get, set, has }` for multi-instance). Same optional-peer broadcasting as `@presence` — without it, state still converges within a single window's round-trips. v1 semantics: last-write-wins.
59
+ - **`make:flow` scaffolding command.** The flagship server-driven layer previously had no generator — every `Component` was hand-written (the audit's #1 DX gap). `bun zt make:flow Name` emits a ready-to-run class with the JSX pragma, imports, and an `@expose`/`render` skeleton: `--child` scaffolds a child component (with `setup(props)`), `--crud` a resourceful page (list + create/edit/delete + `@validate` + a form), and `--layout Foo` wraps a page in a layout (with the correct relative import depth for nested names like `Users/Index`). The target directory is auto-detected (`app/flow/pages` for pages, `app/flow/components` for children) or set with `--dir`; since Flow uses file-based routing the page is served automatically, and the command also prints the explicit `Router.flow(...)` line. Registered lazily by `FlowProvider` in console mode, so it's available in any app that registers the provider.
60
+ - **HTTP fallback for WebSocket-blocked networks (Tier 1).** A client circuit breaker: after `HTTP_FALLBACK_AFTER` (3) failed socket handshakes the bridge stops waiting on the WebSocket and sends action frames as plain `POST /__flow/http` requests, which run the _exact same_ server pipeline (hydrate → dispatch → render → patch) over a collecting fake connection and return the frames as a JSON array the client applies — so actions, validation, flashes, redirects, events, and offline-queue replay all keep working behind strict proxies/firewalls that drop WS upgrades, with no component change. It's a graceful degrade, not a configured mode: WS reconnection keeps running and the client upgrades back automatically the moment the socket succeeds (`onopen` resets the failure count and exits HTTP mode). `this.cancel()` POSTs an out-of-band `$cancel` in HTTP mode too. Trade-offs while degraded: no server-pushed frames (so `@task` arrives as one batched update, not token-by-token) and no broadcast-driven `@presence`/`@shared`/`@on("echo:…")` (those ride the separate broadcasting socket); everything driven by the user's own actions still works. Nothing is sent over HTTP until the socket has actually failed.
61
+ - **Reactive lists (`<For>`) + optimistic collections.** `<For each={this.items} keyBy="id">{(item) => …}</For>` compiles to an Alpine `<template x-for>`, so any client change to the array re-renders the list instantly (server patches still keep it authoritative) — the missing piece that made optimistic list ops impossible while `.map()` renders to static HTML. The item template supports element structure, static attrs, `class`/`className` (→ `:class`), reactive attrs, `on*` arrow handlers (→ Alpine `@event`, `this.`→`$flow.`), and `{item.field}` text (→ `x-text`); anything else is a clear compile error pointing at a raw `x-for` escape hatch. On top of it, `this.appendOptimistic(prop, item)` / `this.removeOptimistic(prop, match)` mutate the array before the server confirms: the change shows at once, **survives interim (broadcast/event) patches** (pending ops are re-applied over each server merge), and is **reconciled when the owning action's patch resolves** — server list on success, automatic rollback on failure. Pure reconciliation core in `client/optimistic.ts`; new `For`/`ForProps` exports.
62
+ - **Dev error overlay.** Under `serve --dev`, an unexpected throw — in a server action **or** the initial `GET` render (`onMount`/`render`) — pops a full-screen overlay (Vite/Next style) with the error class, message, and stack, naming the action + component and dimming framework/`node_modules` frames so app frames stand out. Dismiss with Esc or a backdrop click; after an action error the patch still reconciles underneath, so you return to a live page (pairs with the time-travel timeline). The detail is attached **only under the dev worker** — production sends none (an action flashes; an initial-render error returns a normal 500). Only unexpected throws overlay: validation errors and **intended HTTP errors** (auth `401`/`403`, `404`, redirects — anything carrying a numeric `status`) pass straight through to their normal handling. New module `client/errorOverlay.ts`; the error frame gained optional dev-only `name`/`stack`/`action` fields; the initial-render error rides an embedded `flow-boot-error` script the bridge reads on load.
63
+ - **Type-safe real-time events (Tier 1).** An app-augmentable `FlowEvents` contract maps event names to payload types; `dispatch`/`dispatchTo`/`dispatchSelf` are now generic over it, so a known event's payload is type-checked at every dispatch site (server actions and client expressions alike) — wrong shape, missing payload, or a payload on a `void` event all fail to compile — while unknown names stay untyped (a `string` fallback keeps `echo:…` and gradual adoption compiling, no breakage). `@on` autocompletes to known names; annotate handlers with `EventPayload<K>`. No codegen. An optional runtime guard (`registerFlowEvent(name, guard)`) throws from `dispatch` on a malformed payload — for the untrusted client-originated path the types can't cover. New module `events.ts`; exports `registerFlowEvent`, `FlowEvents`, `EventName`, `EventPayload`.
64
+
65
+ - **`@presence` — multiplayer "who's here" (Tier 1).** Binds a property to a broadcast presence channel (static or a `(self) => string` resolver) and keeps it filled with the live member list, refreshed on join/leave via a new `$presence` built-in. The resolved channel is server-derived and carried signed in the snapshot; the prop is server-controlled (locked). Client-only cursor/typing whispers ride the same channel via `this.whisper(event, data)` / `this.onWhisper(event, cb)`. Broadcasting is an optional peer — resolved through a dynamic import, a no-op when absent — so flow takes no new hard dependency.
66
+ - **Time-travel devtools (Tier 1).** Under `serve --dev`, the client records the signed snapshot stream it already receives — one frame per applied patch plus the initial mount — into a capped ring buffer, and can scrub back to any frame: a jump re-applies that frame's snapshot + HTML to the live component (client-only, no server round-trip), restoring state and DOM exactly. It surfaces as a **Timeline tab in the `@zerotal/devtools` panel** (registered via that package's new `window.__zerotalDevtools` extension API) so all the framework's tooling is unified; when devtools isn't present it falls back to a standalone ⏱ panel. Frames list by action + changed fields + time with click-to-jump and a ⏵ Resume-live button; a `window.__flow.timeline` console API (`frames`/`framesFor`/`jump`/`live`) mirrors it. Dev-gated via the `dev` flag on the WS `ready` frame — production ships none of it. The recording core is a DOM-free module (`client/timeline.ts`); the DOM apply step is injected from the bridge, and the patch morph was extracted to a shared `_morphComponent` reused by both a patch and a jump.
67
+ - **Durable / resumable snapshots (Tier 1).** Opt a component in with `static durable = true` (or `{ ttl, scope }`) and its signed snapshot is persisted server-side after every request, keyed by user (or session) + route. On a fresh GET a valid stored snapshot is restored and `onHydrate()` runs instead of `onMount()`, so an in-progress flow resumes exactly across a full reload, tab close/reopen, device switch, or reconnect after the held client snapshot is gone — nothing stored in the browser. HMAC verification + identity keying isolate users and reject tampered/rotated-key entries (graceful fresh mount). `this.clearDurable()` forgets the entry on flow completion. The store is pluggable via `setDurableStore()` (default in-process TTL store; swap a `@zerotal/cache`/Redis-backed `{ get, set, delete }` for redeploy survival). `scope: "user"` keys by the authenticated user (session fallback when anonymous); `scope: "session"` keys per-device.
68
+ - **`@task` — streaming, cancellable actions (Tier 1).** Mark an async method `@task` (implicitly `@expose`) and write a field in a loop — while the action runs the framework flushes throttled **field-level** snapshot diffs (the changed fields only, no HTML, no per-chunk re-render), so a reactive binding of the field (`text={this.answer}`, `x-text`, a reactive `:attr`) streams to the browser live as `this.answer += token` runs. The field stays the source of truth (no `flow:stream` element, no double-write); the final patch re-renders once to reconcile any static template positions. The triggering control keeps its loading state for the whole run (partial patches don't clear it; only the final patch does). `this.signal` (a standard `AbortSignal`) + `this.cancelled` give cooperative cancellation; the client's `this.cancel()` sends an out-of-band `$cancel` frame (bypassing the per-component send queue the running task occupies), which the server handles before dispatch to abort the task's controller. Implemented via a `partial` patch flag and a chained delta base (each streaming patch is delta-encoded against the previous one the client received).
69
+ - **`@shared` — convergent multiplayer state (Tier 1).** Binds a property to server-authoritative shared state on a channel: mutating it in an action writes to a per-channel **room store** and broadcasts to the channel, so every other subscriber re-reads and converges via a new `$shared` built-in. Before each action the prop is refilled from the store (read-latest); after, changed props are written back and broadcast (`toOthers`). The resolved channel is signed in the snapshot (`SnapshotMemo.shared`) and the prop is server-controlled (locked). The store is pluggable via `setSharedStore()` (default in-process; swap a Redis-backed `{ get, set, has }` for multi-instance). Same optional-peer broadcasting as `@presence` — without it, state still converges within a single window's round-trips. v1 semantics: last-write-wins.
41
70
  - **Delta transport.** WebSocket patches carry only the changed snapshot fields (rebuilt client-side against the copy it holds, still HMAC-verified) via `encodeSnapshotDelta`/`applySnapshotDelta`, and omit `html` when the re-render is byte-identical to the last patch on that connection. `ZT_FLOW_TRANSPORT_LOG=1` logs delta-vs-full sizes.
42
- - **JSX-native layouts.** Override `Component.layout(page)` to wrap the rendered root in any component — named regions are plain props. `static layout` still works; the method wins when both are present. Nav-persistence uses a `data-flow-layout` marker (declared on the shell, else derived from the wrapper source).
43
- - **Named slots on child components.** `slots={{ … }}` + `this.slot(name)` / `this.hasSlot(name)`; slot HTML is carried, signed, in the child snapshot (`SnapshotMemo.slots`).
71
+ - **JSX-native layouts.** Override `Component.layout(page)` to wrap the rendered root in any component — named regions are plain props. `static layout` still works; the method wins when both are present. Nav-persistence uses a `data-flow-layout` marker (declared on the shell, else derived from the wrapper source).
72
+ - **Named slots on child components.** `slots={{ … }}` + `this.slot(name)` / `this.hasSlot(name)`; slot HTML is carried, signed, in the child snapshot (`SnapshotMemo.slots`).
44
73
  - **Compiler prop parity.** `transition`, the `flow:offline` family (`showOnOffline`/`hideOnOffline`/`offlineClass`/`offlineAttr`), `sortItem`, and `loadingTarget`/`dirtyTarget` (`flow:target`) now compile to their directives; the AOT `directives.ts` map and the runtime `PULSE_PROP_MAP` were reconciled.
45
- - **Stateful fast refresh in dev.** Editing a component under `serve --dev` now re-renders each mounted component from its held, still-valid snapshot with the newly-compiled code — **preserving state** — instead of a full reload, and refetches the stylesheet so new classes apply. Implemented as a `$rerender` built-in (a no-op re-render that skips `onMount`), gated to the dev worker via a `dev` flag on the WS `ready` frame, triggered by the flow WS reconnect; falls back to a one-shot full reload if the held snapshot is incompatible with the new code.
46
- - **Tier 2 interaction polish.** `<Skeleton>` pulsing placeholder (+ `<Loading skeleton>` and lazy `placeholder()` use); **hover-prefetch** for `navigate hover` links (fetch-on-hover cache so the click swaps instantly — the prop existed but was unimplemented); and **optimistic-UI failed state** via `showOnError`/`hideOnError` (driven by an `actionError` flag the server sets when an action or a rejected `onUpdating` write throws). Rollback itself was already a property of snapshot reconciliation. `lazy`/`defer` are now typed on component JSX props.
74
+ - **Stateful fast refresh in dev.** Editing a component under `serve --dev` now re-renders each mounted component from its held, still-valid snapshot with the newly-compiled code — **preserving state** — instead of a full reload, and refetches the stylesheet so new classes apply. Implemented as a `$rerender` built-in (a no-op re-render that skips `onMount`), gated to the dev worker via a `dev` flag on the WS `ready` frame, triggered by the flow WS reconnect; falls back to a one-shot full reload if the held snapshot is incompatible with the new code.
75
+ - **Tier 2 interaction polish.** `<Skeleton>` pulsing placeholder (+ `<Loading skeleton>` and lazy `placeholder()` use); **hover-prefetch** for `navigate hover` links (fetch-on-hover cache so the click swaps instantly — the prop existed but was unimplemented); and **optimistic-UI failed state** via `showOnError`/`hideOnError` (driven by an `actionError` flag the server sets when an action or a rejected `onUpdating` write throws). Rollback itself was already a property of snapshot reconciliation. `lazy`/`defer` are now typed on component JSX props.
47
76
 
48
77
  ### Changed
49
78
 
50
- - **Loading indicators wait out a short delay by default (no flash on fast actions).** `showOnLoading`, `hideOnLoading`, `loadingClass`, and `<Loading>` now reveal only after ~200ms in flight, so an action that finishes inside that window shows nothing — no spinner flash. `loadingAttr` (e.g. `loadingAttr="disabled"`) stays **immediate**, so a submit button still guards against a double-click even on a sub-100ms action. Previously only the opt-in `<Loading delay>` / `flow:loading.delay` was delayed; `delay` is now the default and kept for back-compat. Cleared instantly on resolve, so a just-over-threshold action barely blips.
51
- - **`live` text inputs debounce their server sync (~150ms) by default.** A `live` text/textarea input no longer fires a WebSocket `$set` on every keystroke — it debounces ~150ms (matching Livewire's `wire:model.live`), so real-time validation and reactive server state update when you pause, not per character. Discrete controls (checkbox, radio, `<select>`, range) still sync immediately. The DOM and client-reactive bindings update on every keystroke regardless; only the server round-trip waits, and no value is ever lost (local state is current and any action flushes the full snapshot).
52
- - **WebSocket actions serialize per component.** At most one action per component is in flight; the next waits for the previous patch to apply. Required by delta transport (deltas are order-dependent — a pipelined patch computed against a stale base would corrupt the client snapshot and trip the invalid-request rate limit). Offline replay runs through the same serial queue; a 15s ack timeout prevents wedging.
79
+ - **Loading indicators wait out a short delay by default (no flash on fast actions).** `showOnLoading`, `hideOnLoading`, `loadingClass`, and `<Loading>` now reveal only after ~200ms in flight, so an action that finishes inside that window shows nothing — no spinner flash. `loadingAttr` (e.g. `loadingAttr="disabled"`) stays **immediate**, so a submit button still guards against a double-click even on a sub-100ms action. Previously only the opt-in `<Loading delay>` / `flow:loading.delay` was delayed; `delay` is now the default and kept for back-compat. Cleared instantly on resolve, so a just-over-threshold action barely blips.
80
+ - **`live` text inputs debounce their server sync (~150ms) by default.** A `live` text/textarea input no longer fires a WebSocket `$set` on every keystroke — it debounces ~150ms (matching Livewire's `wire:model.live`), so real-time validation and reactive server state update when you pause, not per character. Discrete controls (checkbox, radio, `<select>`, range) still sync immediately. The DOM and client-reactive bindings update on every keystroke regardless; only the server round-trip waits, and no value is ever lost (local state is current and any action flushes the full snapshot).
81
+ - **WebSocket actions serialize per component.** At most one action per component is in flight; the next waits for the previous patch to apply. Required by delta transport (deltas are order-dependent — a pipelined patch computed against a stale base would corrupt the client snapshot and trip the invalid-request rate limit). Offline replay runs through the same serial queue; a 15s ack timeout prevents wedging.
53
82
 
54
83
  ### Fixed
55
84
 
56
- - **`@expose`/`@locked` on a getter now fails loudly.** A getter (a `@computed`, or any accessor) has no writable snapshot storage, so exposing or locking one would serialize a value the framework then tries to write back on the next update — clobbering the getter. TypeScript already rejects it (the decorators' Stage 3 context types exclude getters), but Bun transpiles without typechecking, so the decorator would still run and silently misregister. The `@expose`/`@locked`/`@url`/`@session`/`@transient`/`@reactive`/`@modelable` decorators now throw at class definition on a getter/setter/accessor, with a message pointing to `@computed` (for a derived value) or an `@expose` field (for client-writable state).
57
- - **`@computed` getters compile in a text child.** The AOT compiler only knew about `@expose`/`@locked`, so `{this.uptime}` for a `@computed` getter failed validation and the whole page silently fell back to the runtime renderer. The compiler now recognises `@computed` (it sits on a getter, which the member scan previously skipped) and allows it wherever a text child emits a **static** value (`{this.x}`, `{String(this.x)}`) — matching how `@expose`/`@locked` text children already render (static, updates on the next server patch). Reactive sites still reject it correctly (a `@computed` isn't in the snapshot, so `text={this.x}` / reactive `:class` can't bind to it) — now with a message that says so and points to `{this.x}` or `@expose`. Also corrected the docs claim that `@computed` is "reactive on the client": it renders a static server-evaluated value, not a client-reactive binding.
58
- - **Removed two dead binding-resolution defences.** The runtime resolver's "brute-force own-property scan" and the twin `hasOwnProperty(hint)` fallback existed to recover `@locked` props "whose decorator registration failed to land" — but `getExposedProps`/`getLockedProps` now drain the prototype chain first, so every decorated prop is reliably registered before the value-scan. Verified across the full suite + the showcase that the brute-force branch never fired; it could only ever match a _non-decorated_ field (a false positive), so removing it also removes that footgun.
59
- - **Component bindings resolve statically (compile-time bind-name injection).** The AOT string compiler bails on any function component, so component-heavy pages render through the runtime `jsx()` path, where TSC's jsx transform has already discarded the `show`→`sheetOpen` prop→key mapping. That made value-based bind resolution fragile: a bound child clobbered the getter capture, and props sharing a value (two `false`s) couldn't be told apart, so an overlay-with-a-bound-form could render **unbound** (with only a `console.warn`). A new pass (`buildBindInjectedRender`) reprints a bailed page's `render()` with `__flowBinds={{ show: "sheetOpen" }}` added to every component that binds `show`/`bind`/`query` to `this.<key>`; the component reads it via `_injectedBindKey(props, attr)` (before `name=`/value resolution), so the bound prop is resolved from the source **statically and unambiguously**. The reprint is otherwise identical to the source (behaviour unchanged apart from robust binding), runs through the existing cache as a `.tsx` entry, and falls back to the untouched runtime render on any failure. Wired for every overlay (`Modal`/`Drawer`/`Dialog`/`Sheet`), form control (`Switch`/`Checkbox`/`Select`/`RadioGroup`/`Listbox`), and `Combobox` (`bind` + `query`). `__flowBinds` is stripped by `jsx()` and never emitted as an HTML attribute. (Applies to pages the compiler sees — file-based routes automatically; `Router.flow` pages once their `__sourceFile` is set.) Audit of the other seams: `_resolveValueBind` (`value=`/`checked=`) is already value-identity-guarded and only applies to leaf inputs (no children to clobber the capture); `error=`/`<ErrorMessage>` carries a self-describing sentinel — neither is ambiguous.
60
- - **`text={this.x}` binds to its own property, not a sibling binding's.** On the runtime (non-AOT) render path, `_resolveBindName` trusted a single-slot getter capture — but every prop of a JSX element is evaluated before `jsx()` runs, so a later binding like `class={"row " + this.accent}` overwrote the capture that `text={this.count}` had set. The element then emitted `flow:text="accent"` and rendered the accent string in place of the count. The resolver now value-guards the capture (only trusts it when the captured property's current value is the one being resolved) and falls back to value-identity + the hint, so `text` (and `show`) always resolve to their own property regardless of other bindings on the same element. `value=`/`checked=` were already value-checked and unaffected.
61
- - **`<Head>` content survives SPA navigation.** On a same-layout `navigate`, `_processHead` only ever re-read the live `document` — but the initial hoist removes each `<template data-flow-head>` from the DOM, and a persistent-layout swap carries only the page root (not the shell's `<Head>`). So the managed head was cleared and never restored: a stylesheet or fonts declared in a JSX-native layout's `<Head>` vanished on the first navigation (the page rendered unstyled), and page-level `<title>`/`<meta>` were dropped too. `navigate` now re-applies head from the **incoming** parsed document (which still carries every head template — shell stylesheet and page meta alike), deduping by identity so an unchanged stylesheet is left in place rather than removed-and-re-added (no unstyled flash between visits).
85
+ - **`@expose`/`@locked` on a getter now fails loudly.** A getter (a `@computed`, or any accessor) has no writable snapshot storage, so exposing or locking one would serialize a value the framework then tries to write back on the next update — clobbering the getter. TypeScript already rejects it (the decorators' Stage 3 context types exclude getters), but Bun transpiles without typechecking, so the decorator would still run and silently misregister. The `@expose`/`@locked`/`@url`/`@session`/`@transient`/`@reactive`/`@modelable` decorators now throw at class definition on a getter/setter/accessor, with a message pointing to `@computed` (for a derived value) or an `@expose` field (for client-writable state).
86
+ - **`@computed` getters compile in a text child.** The AOT compiler only knew about `@expose`/`@locked`, so `{this.uptime}` for a `@computed` getter failed validation and the whole page silently fell back to the runtime renderer. The compiler now recognises `@computed` (it sits on a getter, which the member scan previously skipped) and allows it wherever a text child emits a **static** value (`{this.x}`, `{String(this.x)}`) — matching how `@expose`/`@locked` text children already render (static, updates on the next server patch). Reactive sites still reject it correctly (a `@computed` isn't in the snapshot, so `text={this.x}` / reactive `:class` can't bind to it) — now with a message that says so and points to `{this.x}` or `@expose`. Also corrected the docs claim that `@computed` is "reactive on the client": it renders a static server-evaluated value, not a client-reactive binding.
87
+ - **Removed two dead binding-resolution defences.** The runtime resolver's "brute-force own-property scan" and the twin `hasOwnProperty(hint)` fallback existed to recover `@locked` props "whose decorator registration failed to land" — but `getExposedProps`/`getLockedProps` now drain the prototype chain first, so every decorated prop is reliably registered before the value-scan. Verified across the full suite + the showcase that the brute-force branch never fired; it could only ever match a _non-decorated_ field (a false positive), so removing it also removes that footgun.
88
+ - **Component bindings resolve statically (compile-time bind-name injection).** The AOT string compiler bails on any function component, so component-heavy pages render through the runtime `jsx()` path, where TSC's jsx transform has already discarded the `show`→`sheetOpen` prop→key mapping. That made value-based bind resolution fragile: a bound child clobbered the getter capture, and props sharing a value (two `false`s) couldn't be told apart, so an overlay-with-a-bound-form could render **unbound** (with only a `console.warn`). A new pass (`buildBindInjectedRender`) reprints a bailed page's `render()` with `__flowBinds={{ show: "sheetOpen" }}` added to every component that binds `show`/`bind`/`query` to `this.<key>`; the component reads it via `_injectedBindKey(props, attr)` (before `name=`/value resolution), so the bound prop is resolved from the source **statically and unambiguously**. The reprint is otherwise identical to the source (behaviour unchanged apart from robust binding), runs through the existing cache as a `.tsx` entry, and falls back to the untouched runtime render on any failure. Wired for every overlay (`Modal`/`Drawer`/`Dialog`/`Sheet`), form control (`Switch`/`Checkbox`/`Select`/`RadioGroup`/`Listbox`), and `Combobox` (`bind` + `query`). `__flowBinds` is stripped by `jsx()` and never emitted as an HTML attribute. (Applies to pages the compiler sees — file-based routes automatically; `Router.flow` pages once their `__sourceFile` is set.) Audit of the other seams: `_resolveValueBind` (`value=`/`checked=`) is already value-identity-guarded and only applies to leaf inputs (no children to clobber the capture); `error=`/`<ErrorMessage>` carries a self-describing sentinel — neither is ambiguous.
89
+ - **`text={this.x}` binds to its own property, not a sibling binding's.** On the runtime (non-AOT) render path, `_resolveBindName` trusted a single-slot getter capture — but every prop of a JSX element is evaluated before `jsx()` runs, so a later binding like `class={"row " + this.accent}` overwrote the capture that `text={this.count}` had set. The element then emitted `flow:text="accent"` and rendered the accent string in place of the count. The resolver now value-guards the capture (only trusts it when the captured property's current value is the one being resolved) and falls back to value-identity + the hint, so `text` (and `show`) always resolve to their own property regardless of other bindings on the same element. `value=`/`checked=` were already value-checked and unaffected.
90
+ - **`<Head>` content survives SPA navigation.** On a same-layout `navigate`, `_processHead` only ever re-read the live `document` — but the initial hoist removes each `<template data-flow-head>` from the DOM, and a persistent-layout swap carries only the page root (not the shell's `<Head>`). So the managed head was cleared and never restored: a stylesheet or fonts declared in a JSX-native layout's `<Head>` vanished on the first navigation (the page rendered unstyled), and page-level `<title>`/`<meta>` were dropped too. `navigate` now re-applies head from the **incoming** parsed document (which still carries every head template — shell stylesheet and page meta alike), deduping by identity so an unchanged stylesheet is left in place rather than removed-and-re-added (no unstyled flash between visits).
62
91
  - **`lazy`/`defer` on a child component in JSX now actually defer.** `<Widget lazy />` passed `lazy` through as a component _prop_ instead of a `child()` option, so the child mounted eagerly (blocking on its `onMount`) and never showed its placeholder. The JSX runtime now routes `lazy`/`defer` to the child-loading options.
63
- - **Browser Back to the first page did nothing.** SPA `navigate` only re-rendered on `popstate` when the history entry carried the `flowNavigate` marker, which a fresh page load's initial entry lacked — so pressing Back to the first visited page changed the URL but left the page in place. The initial entry is now stamped at startup.
92
+ - **Browser Back to the first page did nothing.** SPA `navigate` only re-rendered on `popstate` when the history entry carried the `flowNavigate` marker, which a fresh page load's initial entry lacked — so pressing Back to the first visited page changed the URL but left the page in place. The initial entry is now stamped at startup.
64
93
  - **`transition` prop.** Was typed but absent from `PULSE_PROP_MAP`, so it emitted an inert `transition` attribute instead of `flow:transition`.
65
94
  - **`children`/`slots` on components type-check.** Added to `JSX.IntrinsicAttributes` so passing children or a `slots` prop to a `Component` no longer errors.
66
95
 
package/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  > Reactive server-side rendering for Zerotal — server-rendered components, streamed over WebSocket, morphed into the DOM by Alpine.
4
4
 
5
+ **Maturity: experimental.** Flow ships on the same version line as the rest of Zerotal, but it is not at the same maturity as the stable packages. Its API may change between minor versions, and it has had far less production exposure than the core, ORM, and HTTP layers. The version number alone doesn't tell you that, which is why it is said here — build on it deliberately.
6
+
5
7
  Flow is Zerotal's server-driven UI layer. Each page is a TypeScript `Component` class whose state lives on the server. You write plain JSX and bind handlers like `onClick={this.save}`; on each interaction the server hydrates the component from a signed snapshot, runs the method, re-renders, and streams only the changed HTML back, which Alpine.js morphs into the page. There is no API layer, no client-side store, and no hand-written client reactivity — the server is the single source of truth.
6
8
 
7
9
  Flow began life as **Geleza** — isiZulu for _"to flow"_ — and the name turned out to describe the architecture better than anything we could invent. Settling on English package names, we translated it rather than replaced it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/flow",
3
- "version": "1.0.3",
3
+ "version": "1.1.0",
4
4
  "license": "MIT",
5
5
  "maturity": "experimental",
6
6
  "private": false,
@@ -45,14 +45,14 @@
45
45
  "@alpinejs/morph": "^3.14.9",
46
46
  "@alpinejs/persist": "^3.14.9",
47
47
  "@alpinejs/resize": "^3.14.9",
48
- "@zerotal/core": "1.0.3",
49
- "@zerotal/orm": "1.0.3",
50
- "@zerotal/validator": "1.0.3",
48
+ "@zerotal/core": "1.1.0",
49
+ "@zerotal/orm": "1.1.0",
50
+ "@zerotal/validator": "1.1.0",
51
51
  "alpinejs": "^3.14.9",
52
52
  "typescript": "^5.8.0"
53
53
  },
54
54
  "devDependencies": {
55
- "@zerotal/broadcasting": "1.0.3",
55
+ "@zerotal/broadcasting": "1.1.0",
56
56
  "@types/alpinejs": "^3.13.10",
57
57
  "@types/alpinejs__anchor": "^3.13.1",
58
58
  "@types/alpinejs__collapse": "^3.13.4",
@@ -62,8 +62,9 @@
62
62
  "@types/alpinejs__resize": "^3.14.0",
63
63
  "bun-types": "^1.3.14"
64
64
  },
65
- "description": "Flow — server-driven reactive UI for Zerotal with signed snapshots, delta transport, and real-time primitives.",
65
+ "description": "Flow (experimental) — server-driven reactive UI for Zerotal with signed snapshots, delta transport, and real-time primitives.",
66
66
  "keywords": [
67
+ "experimental",
67
68
  "zerotal",
68
69
  "bun",
69
70
  "typescript",
package/src/Component.ts CHANGED
@@ -1181,18 +1181,44 @@ export abstract class Component {
1181
1181
  /**
1182
1182
  * Emit the HTML attributes needed for `flow:model` two-way binding.
1183
1183
  *
1184
+ * Pass `optionValue` for one member of a radio group: the radio carries that
1185
+ * fixed value and is `checked` only when the bound property currently equals it.
1186
+ * A radio group is addressed as a unit — every option binds the same property —
1187
+ * so `value={…}`/`checked={…}` on a radio never infers a binding by itself and
1188
+ * this is the way to bind one.
1189
+ *
1184
1190
  * @example
1185
1191
  * <input {...this.bind('name')} />
1186
1192
  * // → <input flow:model="name" value="Alice" />
1187
1193
  *
1194
+ * @example
1195
+ * // A radio group over a `type` property:
1196
+ * {['CUSTOM', 'ROUTE', 'TEAMS'].map((t) => (
1197
+ * <label>
1198
+ * <input type="radio" name="type" {...this.bind('type', t)} /> {t}
1199
+ * </label>
1200
+ * ))}
1201
+ * // → <input type="radio" name="type" flow:model="type" value="ROUTE" checked>
1202
+ *
1188
1203
  * @param key the `@expose` property to two-way bind.
1189
- * @returns the `flow:model` + `value` attributes to spread onto an input.
1204
+ * @param optionValue this radio's own value; omit for text/checkbox/select inputs.
1205
+ * @returns the `flow:model` + `value` (+ `checked`) attributes to spread onto an input.
1190
1206
  * @category State & exposure
1191
1207
  */
1192
- bind(key: string): Record<string, unknown> {
1208
+ bind(key: string, optionValue?: string): Record<string, unknown> {
1209
+ const current = (this as Record<string, unknown>)[key];
1210
+ if (optionValue !== undefined) {
1211
+ return {
1212
+ "flow:model": key,
1213
+ value: optionValue,
1214
+ // String-compare so a numeric property still matches its string option value —
1215
+ // the DOM only ever gives a radio's value back as a string.
1216
+ checked: String(current ?? "") === optionValue,
1217
+ };
1218
+ }
1193
1219
  return {
1194
1220
  "flow:model": key,
1195
- value: (this as Record<string, unknown>)[key] ?? "",
1221
+ value: current ?? "",
1196
1222
  };
1197
1223
  }
1198
1224
 
@@ -1605,6 +1605,14 @@ function _syncModelInputs(comp: FlowComponent): void {
1605
1605
  el.checked = Boolean(serverVal);
1606
1606
  return;
1607
1607
  }
1608
+ // A radio's `value` is its immutable option identifier — the group's state selects
1609
+ // among those, it does not replace them. Assigning `el.value` here (as the generic
1610
+ // path below does) would rewrite every option in the group to the current value and
1611
+ // destroy the choices.
1612
+ if (el instanceof HTMLInputElement && el.type === "radio") {
1613
+ el.checked = el.value === String(serverVal ?? "");
1614
+ return;
1615
+ }
1608
1616
  const str = String(serverVal ?? "");
1609
1617
  if (el.value !== str) el.value = str;
1610
1618
  // A draft-backed field the server just emptied (e.g. after a successful submit) → drop the draft.
@@ -1844,6 +1852,62 @@ function debounce<T extends unknown[]>(fn: (...args: T) => void, ms: number): (.
1844
1852
  };
1845
1853
  }
1846
1854
 
1855
+ /**
1856
+ * Find the nearest ancestor carrying `flow:<event>`, with or without modifiers.
1857
+ *
1858
+ * The compiler emits modifiers into the attribute *name* (`onClick={flow(this.save).stop}`
1859
+ * → `flow:click.stop="save"`), but a `[flow\:click]` attribute selector matches that name
1860
+ * exactly — so every handler that carried a modifier was invisible to the bridge and did
1861
+ * nothing at all, silently. Walking the ancestors and reading the attribute list finds
1862
+ * both forms.
1863
+ */
1864
+ export function _findHandler(
1865
+ target: Element | null,
1866
+ event: string,
1867
+ ): { el: Element; value: string; modifiers: Set<string> } | null {
1868
+ const prefix = `flow:${event}`;
1869
+ for (let el = target; el; el = el.parentElement) {
1870
+ for (const attr of Array.from(el.attributes)) {
1871
+ if (attr.name !== prefix && !attr.name.startsWith(`${prefix}.`)) continue;
1872
+ if (!attr.value) continue;
1873
+ const modifiers = new Set(
1874
+ attr.name.length > prefix.length ? attr.name.slice(prefix.length + 1).split(".") : [],
1875
+ );
1876
+ return { el, value: attr.value, modifiers };
1877
+ }
1878
+ }
1879
+ return null;
1880
+ }
1881
+
1882
+ /**
1883
+ * Whether a click handler should cancel the element's default action.
1884
+ *
1885
+ * `preventDefault()` used to be unconditional, which cancels the *activation behaviour*
1886
+ * of whatever it is placed on — so a radio or checkbox carrying `onClick` never became
1887
+ * checked, and no handler could fix it: the browser restores the control's pre-click
1888
+ * checkedness after listeners run, so assigning `.checked` inside the handler is reverted
1889
+ * a moment later. `onClick` and a form control were effectively mutually exclusive.
1890
+ *
1891
+ * Default only where the default action is the thing in the way — following a link, or
1892
+ * submitting a form. `.prevent` forces it anywhere; `.passive` opts out anywhere.
1893
+ */
1894
+ export function _shouldPreventClickDefault(el: Element, modifiers: Set<string>): boolean {
1895
+ if (modifiers.has("passive")) return false;
1896
+ if (modifiers.has("prevent")) return true;
1897
+ // tagName rather than instanceof: an element adopted from another document (an iframe,
1898
+ // a template) fails `instanceof HTMLAnchorElement` against this window's constructors,
1899
+ // and silently taking the wrong branch here is the whole class of bug being fixed.
1900
+ const tag = el.tagName?.toUpperCase();
1901
+ if (tag === "A") return true;
1902
+ const type = (el.getAttribute?.("type") ?? "").toLowerCase();
1903
+ // A <button> with no type attribute is type="submit" per the HTML spec.
1904
+ if (tag === "BUTTON") return type === "" || type === "submit";
1905
+ // Inputs of type submit/image submit a form; every other control (checkbox, radio,
1906
+ // file, …) needs its activation behaviour left alone.
1907
+ if (tag === "INPUT") return type === "submit" || type === "image";
1908
+ return false;
1909
+ }
1910
+
1847
1911
  function _parseArgs(el: Element): unknown[] {
1848
1912
  const raw = el.getAttribute("data-args") ?? el.getAttribute("flow:args");
1849
1913
  if (!raw) return [];
@@ -1989,9 +2053,18 @@ function _modelKey(el: Element): string | null {
1989
2053
  * coercion for `type="number"` inputs whose canonical value is already numeric. A checkbox
1990
2054
  * returns its boolean `checked`. `existing` (the current canonical value) guards the implicit
1991
2055
  * number coercion so a string-typed field isn't turned into a number behind the author's back.
2056
+ *
2057
+ * A radio returns its value only when it is the checked member of its group, and
2058
+ * `undefined` otherwise — callers must skip `undefined` rather than write it. Every
2059
+ * radio in a group carries the same `flow:model`, so returning a bare `.value` would
2060
+ * let each unchecked option overwrite the group's state in turn and leave whichever
2061
+ * one happens to be last in DOM order as the winner.
1992
2062
  */
1993
2063
  function _readModelValue(el: ModelEl, existing?: unknown): unknown {
1994
2064
  if (el instanceof HTMLInputElement && el.type === "checkbox") return el.checked;
2065
+ if (el instanceof HTMLInputElement && el.type === "radio") {
2066
+ return el.checked ? el.value : undefined;
2067
+ }
1995
2068
  let v = (el as HTMLInputElement).value;
1996
2069
  if (el.hasAttribute("data-flow-trim")) v = v.trim();
1997
2070
  const wantNumber =
@@ -2010,6 +2083,9 @@ function _flushModelInputs(comp: FlowComponent): void {
2010
2083
  if (!key) return;
2011
2084
  // Coerce (see _readModelValue) so a number field doesn't send "0" as a string, etc.
2012
2085
  const value = _readModelValue(el, _getPath(comp.reactive as Record<string, unknown>, key));
2086
+ // An unchecked radio reports undefined — skip it, or iterating the group would
2087
+ // clear the value its checked sibling just contributed.
2088
+ if (value === undefined) return;
2013
2089
  // reactive first (see set-trap note): ephemeral is the proxy's raw target.
2014
2090
  _setPath(comp.reactive, key, value);
2015
2091
  _setPath(comp.ephemeral, key, value);
@@ -2072,13 +2148,14 @@ function _setupEventDelegation(): void {
2072
2148
  });
2073
2149
 
2074
2150
  document.addEventListener("click", async (e) => {
2075
- const el = (e.target as Element).closest("[flow\\:click]") as Element | null;
2076
- if (!el) return;
2077
- const method = el.getAttribute("flow:click");
2078
- if (!method) return;
2151
+ const found = _findHandler(e.target as Element, "click");
2152
+ if (!found) return;
2153
+ const { el, value: method, modifiers } = found;
2079
2154
  const comp = findComponentByEl(el);
2080
2155
  if (!comp) return;
2081
- e.preventDefault();
2156
+
2157
+ if (modifiers.has("stop")) e.stopPropagation();
2158
+ if (_shouldPreventClickDefault(el, modifiers)) e.preventDefault();
2082
2159
 
2083
2160
  // Async: a confirm prompt shows the styled dialog and resolves on Confirm/Cancel.
2084
2161
  if (!(await _requireConfirm(el))) return;
@@ -2130,6 +2207,9 @@ function _setupEventDelegation(): void {
2130
2207
  // Apply the trim/number modifiers on the way in, so client-reactive bindings see the coerced
2131
2208
  // value immediately (not just the server on the next action).
2132
2209
  const value = _readModelValue(el, _getPath(comp.reactive as Record<string, unknown>, key));
2210
+ // Only the newly-checked radio in a group reports a value; the input event that
2211
+ // deselects its sibling carries nothing to write.
2212
+ if (value === undefined) return;
2133
2213
 
2134
2214
  // reactive first (see set-trap note): ephemeral is the proxy's raw target, so writing it
2135
2215
  // first would suppress the reactive trigger and leave x-text/:class bindings stale.
@@ -2168,7 +2248,16 @@ function _setupEventDelegation(): void {
2168
2248
  if (!comp) return;
2169
2249
 
2170
2250
  const key = el.getAttribute("flow:model.blur");
2171
- if (key) _callAction(comp, "$set", [key, (el as HTMLInputElement).value]);
2251
+ if (key) {
2252
+ // Go through _readModelValue so the blur path applies the same coercions and
2253
+ // the same radio/checkbox rules as input and flush do — a raw `.value` here
2254
+ // sends "on" for a checkbox and an unchecked radio's value for a radio.
2255
+ const blurValue = _readModelValue(
2256
+ el as ModelEl,
2257
+ _getPath(comp.reactive as Record<string, unknown>, key),
2258
+ );
2259
+ if (blurValue !== undefined) _callAction(comp, "$set", [key, blurValue]);
2260
+ }
2172
2261
 
2173
2262
  // `onBlur={this.method}` — the JSX runtime emits `flow:blur` for it like any
2174
2263
  // other `on*` prop, so without this the attribute renders and nothing ever
@@ -169,4 +169,35 @@ export async function compileRegisteredPages(appRoot: string, cspSafe = false):
169
169
  { compiled, cached, injected, runtime: skipped, ms },
170
170
  );
171
171
  }
172
+
173
+ _warnIfMostlyRuntime(compiled + cached + injected + skipped, skipped);
172
174
  }
175
+
176
+ /**
177
+ * Falling back to the runtime renderer for a page or two is normal and correct.
178
+ * A whole app doing it is not: the runtime path resolves `value=`/`checked=`
179
+ * bindings by observing property reads at render time, which is necessarily
180
+ * weaker than the compiler reading the same intent out of the AST. An app that
181
+ * never compiles is relying on that weaker path everywhere without being told.
182
+ *
183
+ * So the per-page reasons stay behind ZT_FLOW_COMPILE_LOG (they are noise at a
184
+ * handful of pages), but the *rate* is reported unconditionally once it passes
185
+ * half. The message names the flag, because knowing which construct blocks each
186
+ * page is the only actionable next step.
187
+ */
188
+ function _warnIfMostlyRuntime(total: number, runtime: number): void {
189
+ if (total < MIN_PAGES_FOR_FALLBACK_WARNING) return;
190
+ if (runtime * 2 <= total) return;
191
+ if (_explainFallbacks()) return; // already printing a reason per page
192
+
193
+ const pct = Math.round((runtime / total) * 100);
194
+ frameworkLog("flow").warn(
195
+ `${runtime} of ${total} Flow pages (${pct}%) render through the runtime fallback ` +
196
+ `instead of compiled output.\n` +
197
+ ` Set ZT_FLOW_COMPILE_LOG=1 to print what blocks each page.`,
198
+ { total, runtime, pct },
199
+ );
200
+ }
201
+
202
+ /** Below this, a high percentage is not yet evidence of anything. */
203
+ const MIN_PAGES_FOR_FALLBACK_WARNING = 4;
@@ -448,14 +448,39 @@ interface MemberInfo {
448
448
  locked: Set<string>; // @locked only — readable from client, not writable
449
449
  computed: Set<string>; // @computed getters — derived, NOT in the snapshot
450
450
  readable: Set<string>; // exposed ∪ locked — any prop in the snapshot (client-reactive)
451
+ /** Every method declared on this class, decorated or not — see `_assertActionExposed`. */
452
+ declaredMethods: Set<string>;
453
+ /**
454
+ * Methods the client may call. `@expose` is the usual one, but `@task` and `@on` both
455
+ * register into the same allowlist (`_exposedMethods`) — a `@task` streams field writes
456
+ * and is dispatched exactly like any other action. Checking only for `@expose` rejected
457
+ * perfectly good pages.
458
+ */
459
+ callableMethods: Set<string>;
451
460
  }
452
461
 
462
+ /** Decorators that put a method in the client-callable allowlist. */
463
+ const ACTION_DECORATORS = new Set(["expose", "task", "on"]);
464
+
453
465
  /** Collect @expose, @locked, and @computed decorated members from the Component class AST. */
454
466
  function _getMemberInfo(cls: ts.ClassDeclaration): MemberInfo {
455
467
  const exposed = new Set<string>();
456
468
  const locked = new Set<string>();
457
469
  const computed = new Set<string>();
470
+ const declaredMethods = new Set<string>();
471
+ const callableMethods = new Set<string>();
458
472
  for (const member of cls.members) {
473
+ if (ts.isMethodDeclaration(member) && ts.isIdentifier(member.name)) {
474
+ declaredMethods.add(member.name.text);
475
+ for (const m of member.modifiers ?? []) {
476
+ if (!ts.isDecorator(m)) continue;
477
+ // `@on("evt")` is a call; `@expose` is a bare identifier.
478
+ const e = ts.isCallExpression(m.expression) ? m.expression.expression : m.expression;
479
+ if (ts.isIdentifier(e) && ACTION_DECORATORS.has(e.text)) {
480
+ callableMethods.add(member.name.text);
481
+ }
482
+ }
483
+ }
459
484
  // @computed sits on a getter (GetAccessorDeclaration); @expose/@locked on fields/methods.
460
485
  if (
461
486
  !ts.isPropertyDeclaration(member) &&
@@ -475,7 +500,55 @@ function _getMemberInfo(cls: ts.ClassDeclaration): MemberInfo {
475
500
  if (has("locked")) locked.add(name);
476
501
  if (has("computed")) computed.add(name);
477
502
  }
478
- return { exposed, locked, computed, readable: new Set([...exposed, ...locked]) };
503
+ return {
504
+ exposed,
505
+ locked,
506
+ computed,
507
+ readable: new Set([...exposed, ...locked]),
508
+ declaredMethods,
509
+ callableMethods,
510
+ };
511
+ }
512
+
513
+ /**
514
+ * Reject `onClick={this.method}` when `method` is declared on the page but not `@expose`d.
515
+ *
516
+ * `getAllowedMethods()` is `getExposedMethods()`, so an undecorated method is simply absent
517
+ * from the allowlist. Everything else about the page passes — `tsc` is clean, the compiler
518
+ * emits `flow:click="submit"`, the button renders enabled — and the click sends a frame the
519
+ * server refuses, reporting it *only* over the WebSocket to `console.error`:
520
+ *
521
+ * [Flow] Server error on …: Method "submit" is not allowed
522
+ *
523
+ * Nothing reaches the server log, nothing throws, the page does not change. To a developer,
524
+ * and to every test that is not a real browser with the console open, the button simply does
525
+ * nothing — indistinguishable from the binding bugs, so the instinct is to go hunting for one.
526
+ *
527
+ * The member table needed to catch this is already built, so catch it here.
528
+ *
529
+ * Only fires for a method *declared on this class*: an inherited handler (a base-class action,
530
+ * a mixin) is invisible to this AST and must not be second-guessed.
531
+ */
532
+ function _assertActionExposed(
533
+ method: string,
534
+ members: MemberInfo,
535
+ filename: string,
536
+ node: ts.Node,
537
+ ): void {
538
+ if (!members.declaredMethods.has(method)) return; // inherited or a magic — not ours to judge
539
+ if (members.callableMethods.has(method)) return;
540
+ if (CLIENT_CALLBACK_MAGICS.has(method) || CLIENT_MAGICS.has(method)) return;
541
+
542
+ const sf = node.getSourceFile();
543
+ const { line, character } = sf.getLineAndCharacterOfPosition(node.getStart(sf));
544
+ throw new FlowValidationError(
545
+ `[Flow] \`${method}\` is used as a server action but is not @expose'd.\n` +
546
+ ` ${_at(filename, line + 1, character + 1)}\n` +
547
+ ` → Add @expose to ${method}() (or @task / @on, which also make it callable). ` +
548
+ `Without it the method is not in the action ` +
549
+ `allowlist, so the click sends a frame the server refuses — and the refusal is ` +
550
+ `reported only to the browser console, which makes the button look inert.`,
551
+ );
479
552
  }
480
553
 
481
554
  // ── this.xxx reference extraction (read vs write) ─────────────────────────────
@@ -685,6 +758,18 @@ function _validateFlowCallbacks(sf: ts.SourceFile, members: MemberInfo, filename
685
758
  // its this.xxx refs must be @expose (writes) / @expose|@locked (reads).
686
759
  _validateArrowRefs(expr, members, filename);
687
760
  }
761
+
762
+ // onClick={this.method} / onSubmit={flow(this.method)} — the method must be
763
+ // @expose'd or it is not in the action allowlist. Checked here, in the pass that
764
+ // runs unconditionally and whose errors are fatal, rather than during transform:
765
+ // a transform error is caught and downgraded to a runtime fallback, which would
766
+ // leave the page rendering and the button still silently dead.
767
+ if (attrName.startsWith("on") || attrName === "submit") {
768
+ const ref = _isThisProp(expr)
769
+ ? (expr as ts.PropertyAccessExpression)
770
+ : _extractFlowProp(expr);
771
+ if (ref) _assertActionExposed(ref.name.text, members, filename, ref);
772
+ }
688
773
  }
689
774
  ts.forEachChild(node, scan);
690
775
  }
@@ -1671,7 +1756,8 @@ function _compileRenderMethod(
1671
1756
  const event = eventName;
1672
1757
  // onClick={this.method} — server action (named method reference).
1673
1758
  if (_isThisProp(expr)) {
1674
- emitStatic(` flow:${event}="${(expr as ts.PropertyAccessExpression).name.text}"`);
1759
+ const method = (expr as ts.PropertyAccessExpression).name.text;
1760
+ emitStatic(` flow:${event}="${method}"`);
1675
1761
  continue;
1676
1762
  }
1677
1763
  // onClick={(e) => this.x = 0} — bare arrow → client expression, emitted verbatim
@@ -1680,6 +1766,20 @@ function _compileRenderMethod(
1680
1766
  // key off it). The <For> path below is the one exception — a loop handler must be an
1681
1767
  // Alpine `@event`, because the loop variable only exists in Alpine's x-for scope.
1682
1768
  if (ts.isArrowFunction(expr)) {
1769
+ // `() => this.method(row.id)` — a server action that wants an argument. The
1770
+ // arrow itself cannot ship: it would be emitted verbatim and `row` does not
1771
+ // exist in the browser. But this render function DOES have `row` in scope at
1772
+ // request time, so emit the action by name and serialise the arguments beside
1773
+ // it as data-args, which the bridge already reads and forwards. Matches what
1774
+ // the runtime renderer achieves by invoking the arrow with a recorder.
1775
+ const argCall = _asServerActionCall(expr, members);
1776
+ if (argCall) {
1777
+ emitStatic(` flow:${event}="${argCall.method}"`);
1778
+ emitStatic(` data-args="`);
1779
+ emitDynamic(`__escAttr(JSON.stringify([${argCall.argsSrc}]))`);
1780
+ emitStatic(`"`);
1781
+ continue;
1782
+ }
1683
1783
  if (_cspSafe) {
1684
1784
  // CSP-safe: emit the bare expression body (no arrow wrapper). An event
1685
1785
  // param or a block body has no CSP-safe inline form → compile error.
@@ -1695,11 +1795,13 @@ function _compileRenderMethod(
1695
1795
  ` Use a single expression or an @expose action.\n File: ${filename}`,
1696
1796
  );
1697
1797
  }
1798
+ _assertClientExprScope(expr, members, filename);
1698
1799
  const body = _rewriteClientExpr(expr.body.getText());
1699
1800
  _assertCspExpr(body, filename);
1700
1801
  emitStatic(` flow:${event}="${_escAttr(body)}"`);
1701
1802
  continue;
1702
1803
  }
1804
+ _assertClientExprScope(expr, members, filename);
1703
1805
  const src = _rewriteClientExpr(expr.getText());
1704
1806
  emitStatic(` flow:${event}="${_escAttr(src)}"`);
1705
1807
  continue;
@@ -2047,9 +2149,246 @@ function _clientMagicSrc(expr: ts.Expression, members: MemberInfo, filename: str
2047
2149
  );
2048
2150
  }
2049
2151
  }
2152
+ // Same verbatim-emission hazard as an arrow handler: this expression re-evaluates in
2153
+ // the browser, so a captured server-side name would silently evaluate to undefined.
2154
+ _assertClientExprScope(expr, members, filename);
2050
2155
  return _rewriteClientExpr(expr.getText());
2051
2156
  }
2052
2157
 
2158
+ /**
2159
+ * Recognise `() => this.method(a, b)` — a no-parameter arrow whose whole body is one
2160
+ * call to a component action with arguments — and return the pieces needed to emit it
2161
+ * as `flow:<event>="method"` + `data-args`.
2162
+ *
2163
+ * Returns null (leaving the caller on the verbatim client-expression path) when:
2164
+ * - the arrow takes parameters (it wants the DOM event; that's genuinely client-side);
2165
+ * - the body isn't a single call, or isn't a call to `this.something`;
2166
+ * - the callee isn't an @expose action (a client magic like `this.refresh()` must stay
2167
+ * a client expression);
2168
+ * - **any argument reads `this`** — `this.count + 1` must re-evaluate on the client
2169
+ * against live reactive state, not be frozen into the markup at render time.
2170
+ */
2171
+ function _asServerActionCall(
2172
+ arrow: ts.ArrowFunction,
2173
+ members: MemberInfo,
2174
+ ): { method: string; argsSrc: string } | null {
2175
+ if (arrow.parameters.length > 0) return null;
2176
+
2177
+ const body = ts.isBlock(arrow.body)
2178
+ ? arrow.body.statements.length === 1 && ts.isExpressionStatement(arrow.body.statements[0]!)
2179
+ ? (arrow.body.statements[0] as ts.ExpressionStatement).expression
2180
+ : null
2181
+ : arrow.body;
2182
+ if (!body || !ts.isCallExpression(body)) return null;
2183
+ if (!ts.isPropertyAccessExpression(body.expression)) return null;
2184
+ if (body.expression.expression.kind !== ts.SyntaxKind.ThisKeyword) return null;
2185
+
2186
+ const method = body.expression.name.text;
2187
+ if (CLIENT_CALLBACK_MAGICS.has(method) || CLIENT_MAGICS.has(method)) return null;
2188
+ // Actions are @expose methods, so `exposed` is the whole allowlist.
2189
+ if (!members.exposed.has(method)) return null;
2190
+ if (body.arguments.length === 0) return null; // no args → plain flow:click="method"
2191
+
2192
+ for (const arg of body.arguments) {
2193
+ if (_referencesThis(arg)) return null;
2194
+ }
2195
+
2196
+ return { method, argsSrc: body.arguments.map((a) => a.getText()).join(", ") };
2197
+ }
2198
+
2199
+ /** True when `node` reads `this` anywhere (so it must stay a live client expression). */
2200
+ function _referencesThis(node: ts.Node): boolean {
2201
+ if (node.kind === ts.SyntaxKind.ThisKeyword) return true;
2202
+ let found = false;
2203
+ ts.forEachChild(node, (child) => {
2204
+ if (!found && _referencesThis(child)) found = true;
2205
+ });
2206
+ return found;
2207
+ }
2208
+
2209
+ // ── Client-expression scope validation ────────────────────────────────────────
2210
+
2211
+ /**
2212
+ * Names a client expression may reference besides `$flow`, its own parameters, and
2213
+ * its own locals: Alpine's magics (the expression is evaluated by Alpine) and the
2214
+ * browser globals it is reasonable to reach for inline.
2215
+ */
2216
+ const CLIENT_EXPR_GLOBALS = new Set([
2217
+ // Alpine magics
2218
+ "$el",
2219
+ "$event",
2220
+ "$refs",
2221
+ "$store",
2222
+ "$dispatch",
2223
+ "$watch",
2224
+ "$nextTick",
2225
+ "$id",
2226
+ "$root",
2227
+ "$data",
2228
+ "$flow",
2229
+ // Language / literals
2230
+ "undefined",
2231
+ "NaN",
2232
+ "Infinity",
2233
+ "globalThis",
2234
+ "arguments",
2235
+ // Standard built-ins
2236
+ "Object",
2237
+ "Array",
2238
+ "String",
2239
+ "Number",
2240
+ "Boolean",
2241
+ "Math",
2242
+ "JSON",
2243
+ "Date",
2244
+ "RegExp",
2245
+ "Map",
2246
+ "Set",
2247
+ "Promise",
2248
+ "Error",
2249
+ "Symbol",
2250
+ "BigInt",
2251
+ "Intl",
2252
+ "parseInt",
2253
+ "parseFloat",
2254
+ "isNaN",
2255
+ "isFinite",
2256
+ "encodeURIComponent",
2257
+ "decodeURIComponent",
2258
+ "encodeURI",
2259
+ "decodeURI",
2260
+ "structuredClone",
2261
+ // Browser
2262
+ "window",
2263
+ "document",
2264
+ "console",
2265
+ "navigator",
2266
+ "location",
2267
+ "history",
2268
+ "localStorage",
2269
+ "sessionStorage",
2270
+ "fetch",
2271
+ "URL",
2272
+ "URLSearchParams",
2273
+ "FormData",
2274
+ "Event",
2275
+ "CustomEvent",
2276
+ "alert",
2277
+ "confirm",
2278
+ "prompt",
2279
+ "setTimeout",
2280
+ "clearTimeout",
2281
+ "setInterval",
2282
+ "clearInterval",
2283
+ "requestAnimationFrame",
2284
+ "cancelAnimationFrame",
2285
+ "matchMedia",
2286
+ "getComputedStyle",
2287
+ ]);
2288
+
2289
+ /** Collect every name a binding pattern introduces (`{a, b: [c]}`, `...rest`). */
2290
+ function _collectBoundNames(name: ts.BindingName, into: Set<string>): void {
2291
+ if (ts.isIdentifier(name)) {
2292
+ into.add(name.text);
2293
+ return;
2294
+ }
2295
+ for (const element of name.elements) {
2296
+ if (ts.isOmittedExpression(element)) continue;
2297
+ _collectBoundNames(element.name, into);
2298
+ }
2299
+ }
2300
+
2301
+ /**
2302
+ * Reject a client expression that references a name the browser will not have.
2303
+ *
2304
+ * Client expressions are emitted as source text with `this.` rewritten to `$flow.`;
2305
+ * every other identifier is emitted verbatim. So a handler that closes over an
2306
+ * enclosing scope — the loop variable in
2307
+ * `{items.map((item) => <button onClick={() => this.select(item.id)}>)}` — compiles
2308
+ * to `$flow.select(item.id)`, where `item` does not exist. At runtime that throws a
2309
+ * ReferenceError inside the bridge's evaluator, which logs to the browser console
2310
+ * and returns: the click does nothing, the server never hears about it, and the page
2311
+ * looks perfect. Catching it here turns a silent dead button into a build error that
2312
+ * names the identifier.
2313
+ *
2314
+ * Server actions that need a row id take arguments instead — `onClick={this.remove}`
2315
+ * with `data-args={JSON.stringify([row.id])}`.
2316
+ */
2317
+ function _assertClientExprScope(expr: ts.Expression, members: MemberInfo, filename: string): void {
2318
+ // Names bound *within* the expression (arrow params, block locals, catch bindings).
2319
+ const scoped = new Set<string>();
2320
+
2321
+ const collectScope = (node: ts.Node): void => {
2322
+ if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) {
2323
+ for (const param of node.parameters) _collectBoundNames(param.name, scoped);
2324
+ } else if (ts.isVariableDeclaration(node)) {
2325
+ _collectBoundNames(node.name, scoped);
2326
+ } else if (ts.isCatchClause(node) && node.variableDeclaration) {
2327
+ _collectBoundNames(node.variableDeclaration.name, scoped);
2328
+ }
2329
+ ts.forEachChild(node, collectScope);
2330
+ };
2331
+ collectScope(expr);
2332
+
2333
+ const check = (node: ts.Node): void => {
2334
+ // `a.b` — only `a` is a free identifier; `b` is a property name.
2335
+ if (ts.isPropertyAccessExpression(node)) {
2336
+ check(node.expression);
2337
+ return;
2338
+ }
2339
+ // `{ key: value }` — `key` is not a reference.
2340
+ if (ts.isPropertyAssignment(node)) {
2341
+ check(node.initializer);
2342
+ return;
2343
+ }
2344
+ if (ts.isShorthandPropertyAssignment(node)) {
2345
+ // `{ x }` — `x` IS a reference.
2346
+ _checkIdentifier(node.name, scoped, members, filename, expr);
2347
+ return;
2348
+ }
2349
+ if (ts.isIdentifier(node)) {
2350
+ _checkIdentifier(node, scoped, members, filename, expr);
2351
+ return;
2352
+ }
2353
+ ts.forEachChild(node, check);
2354
+ };
2355
+ // Enter through `check`, not forEachChild: when the whole expression IS a property
2356
+ // access (`$flow.store.ui.open`), forEachChild would hand us its trailing name as a
2357
+ // bare identifier and we would reject the root of the chain's own property.
2358
+ check(expr);
2359
+ }
2360
+
2361
+ function _checkIdentifier(
2362
+ id: ts.Identifier,
2363
+ scoped: Set<string>,
2364
+ members: MemberInfo,
2365
+ filename: string,
2366
+ expr: ts.Expression,
2367
+ ): void {
2368
+ const name = id.text;
2369
+ if (scoped.has(name)) return;
2370
+ if (CLIENT_EXPR_GLOBALS.has(name)) return;
2371
+ if (name.startsWith("$")) return; // an Alpine plugin magic we don't know about
2372
+
2373
+ const sf = id.getSourceFile();
2374
+ const { line, character } = sf.getLineAndCharacterOfPosition(id.getStart(sf));
2375
+
2376
+ // A member of the component is the most likely intent, and has a precise fix.
2377
+ const isMember = members.readable.has(name) || members.exposed.has(name);
2378
+ const hint = isMember
2379
+ ? `Did you mean this.${name}? A bare \`${name}\` is not in scope in the browser.`
2380
+ : `\`${name}\` comes from an enclosing server-side scope, and client expressions ` +
2381
+ `are emitted verbatim — it will not exist in the browser.\n` +
2382
+ ` To pass a value to a server action, use data-args:\n` +
2383
+ ` <button onClick={this.method} data-args={JSON.stringify([${name}])}>`;
2384
+
2385
+ throw new FlowValidationError(
2386
+ `[Flow] client expression references \`${name}\`, which will be undefined in the browser.\n` +
2387
+ ` ${_at(filename, line + 1, character + 1)} in \`${_snippet(expr, 60)}\`\n` +
2388
+ ` → ${hint}`,
2389
+ );
2390
+ }
2391
+
2053
2392
  function _isComponent(tag: string): boolean {
2054
2393
  const c = tag.charCodeAt(0);
2055
2394
  return c >= 65 && c <= 90; // A–Z
@@ -10,7 +10,11 @@
10
10
  import { AsyncLocalStorage } from "node:async_hooks";
11
11
  import { escapeHtml } from "@zerotal/core/helpers";
12
12
  import { getExposedProps, getLockedProps } from "./decorators.ts";
13
- import { _setExposedKeyCapture, _consumeExposedKeyCapture } from "./utils.ts";
13
+ import {
14
+ _setExposedKeyCapture,
15
+ _consumeExposedKeyCapture,
16
+ _consumeExposedKeyReads,
17
+ } from "./utils.ts";
14
18
  import { URL_ATTRIBUTES, sanitizeUrl } from "./urlSafety.ts";
15
19
 
16
20
  /** The rendered output of a JSX element: an object carrying its serialized HTML string. */
@@ -557,18 +561,27 @@ export function _resolveBindName(
557
561
  * capture AND a value-identity match, so a literal or server value never false-positives),
558
562
  * or null when the attribute is just an ordinary server-rendered value.
559
563
  * `locked` distinguishes @locked (read-only, reactive) from @expose (two-way).
564
+ *
565
+ * @remarks
566
+ * The getter capture is a single slot and *all* of an element's props are evaluated
567
+ * before `jsx()` runs, so any later prop that reads an exposed property clobbers it —
568
+ * `value={this.destination} disabled={this.notSure}` leaves the capture pointing at
569
+ * `notSure`. The freshness check then fails and the element renders with no
570
+ * `flow:model` at all: the field accepts typing and nothing ever reaches the server.
571
+ * When the capture is unusable we therefore fall back to a value-identity scan over
572
+ * the exposed props, exactly as `_resolveBindName` does — binding correctly whenever
573
+ * the answer is unambiguous, rather than silently dropping the binding.
560
574
  */
561
575
  function _resolveValueBind(value: unknown): { name: string; locked: boolean } | null {
562
576
  const page = _getRenderCtx()?.page ?? null;
563
577
  if (!page) return null;
564
578
  const cap = _consumeExposedKeyCapture();
565
- if (!cap) return null;
566
579
  const exposed = getExposedProps(page); // includes @locked
567
580
 
568
581
  // Dotted capture (e.g. "form.email") — a Form field. The root must be an exposed
569
582
  // prop and the nested value must identity-match (freshness guard). Form fields
570
583
  // are always writable (two-way), so locked is false.
571
- if (cap.includes(".")) {
584
+ if (cap && cap.includes(".")) {
572
585
  const root = cap.slice(0, cap.indexOf("."));
573
586
  if (!exposed.has(root)) return null;
574
587
  const nested = cap
@@ -578,11 +591,29 @@ function _resolveValueBind(value: unknown): { name: string; locked: boolean } |
578
591
  return { name: cap, locked: false };
579
592
  }
580
593
 
581
- if (!exposed.has(cap)) return null;
582
- // Confirm the capture is fresh for THIS value (guards against a stale capture
583
- // lingering from a previous element that didn't consume it).
584
- if (!Object.is((page as Record<string, unknown>)[cap], value)) return null;
585
- return { name: cap, locked: getLockedProps(page).has(cap) };
594
+ // Capture fast path: trust it only when it is fresh for THIS value (guards against
595
+ // a stale capture lingering from a previous element that didn't consume it).
596
+ if (cap && exposed.has(cap) && Object.is((page as Record<string, unknown>)[cap], value)) {
597
+ _consumeExposedKeyReads(); // this element is resolved; don't leak reads to the next
598
+ return { name: cap, locked: getLockedProps(page).has(cap) };
599
+ }
600
+
601
+ // The single slot was clobbered by a later prop on the same element (the B2 case:
602
+ // `value={this.destination} disabled={this.notSure}`). Fall back to the keys actually
603
+ // read while this element's props were evaluated, most recent first, and take the one
604
+ // whose current value IS this value.
605
+ //
606
+ // Crucially this is NOT a scan of every exposed prop: a key only qualifies if it was
607
+ // genuinely read through a `this.` getter, so a literal `value="CUSTOM"` that happens
608
+ // to equal some property's current value still binds nothing.
609
+ const reads = _consumeExposedKeyReads();
610
+ for (const key of reads) {
611
+ if (key === cap) continue; // already rejected above
612
+ if (!exposed.has(key)) continue;
613
+ if (!Object.is((page as Record<string, unknown>)[key], value)) continue;
614
+ return { name: key, locked: getLockedProps(page).has(key) };
615
+ }
616
+ return null;
586
617
  }
587
618
 
588
619
  /**
@@ -1061,6 +1092,18 @@ export function jsx(
1061
1092
  attrs.push(`value="${escapeAttr(String(v ?? ""))}"`);
1062
1093
  continue;
1063
1094
  }
1095
+ // A radio's `value` is the option's identifier and its `checked` is a per-option
1096
+ // boolean — neither is a two-way binding target, and inferring one is actively
1097
+ // wrong. In a group rendered from a .map() the inference lands on whichever single
1098
+ // option currently equals the bound property, so that one radio claims the whole
1099
+ // group's model and overwrites the user's pick on every flush. Bind a radio group
1100
+ // explicitly with {...this.bind('prop', 'optionValue')}, which knows the group is
1101
+ // addressed as a unit.
1102
+ if (type === "input" && String(p["type"] ?? "") === "radio") {
1103
+ if (k === "value") attrs.push(`value="${escapeAttr(String(v ?? ""))}"`);
1104
+ else if (v) attrs.push("checked");
1105
+ continue;
1106
+ }
1064
1107
  const bound = _resolveValueBind(v);
1065
1108
  if (bound) {
1066
1109
  if (bound.locked) {
@@ -274,6 +274,22 @@ async function _handleFlowMessage(ws: FlowBunWs, raw: string, deps: FlowWsDeps):
274
274
  await deps.container.runScoped(async (scoped) => {
275
275
  const ctx = new HttpContext(request, scoped);
276
276
 
277
+ // A WebSocket action has no Bun `Server` behind it, so `ctx.ip()` — which resolves
278
+ // through `server.requestIP(request)` — would return null for every action even
279
+ // though the socket's peer address is known here and was captured at upgrade. Supply
280
+ // a minimal provider so `request().ip()` answers the same inside an action as it does
281
+ // during the initial HTTP render, instead of silently degrading to null.
282
+ const socketIp = ws.data.remoteIp;
283
+ if (socketIp && socketIp !== "unknown") {
284
+ ctx._server = {
285
+ requestIP: () => ({
286
+ address: socketIp,
287
+ family: socketIp.includes(":") ? "IPv6" : "IPv4",
288
+ port: 0, // not carried across the upgrade; nothing reads it
289
+ }),
290
+ };
291
+ }
292
+
277
293
  await RequestContext.run(ctx, async () => {
278
294
  let passed = false;
279
295
  let redirectUrl: string | undefined;
package/src/testing.ts CHANGED
@@ -54,6 +54,10 @@ export class FlowTest<T extends Component> {
54
54
  private _effects: FlowEffects | null = null;
55
55
  private _snapshot: Snapshot | null = null;
56
56
  private _name: string;
57
+ /** Set by {@link tolerateErrors}: capture action errors instead of rethrowing them. */
58
+ private _tolerateErrors = false;
59
+ /** The last non-validation error an action threw, when {@link tolerateErrors} is on. */
60
+ private _lastError: Error | null = null;
57
61
 
58
62
  private constructor(page: T, name: string) {
59
63
  this._page = page;
@@ -97,13 +101,43 @@ export class FlowTest<T extends Component> {
97
101
  // ── Actions ────────────────────────────────────────────────────────────────
98
102
 
99
103
  /**
100
- * Set a property value directly (state seeding), then return for chaining.
101
- * Does NOT fire the updating/updated hooks — use `update()` to simulate a
102
- * client-driven property change.
104
+ * Set a property value directly (state seeding) and re-render, then return for
105
+ * chaining. Does NOT fire the updating/updated hooks — use `update()` to
106
+ * simulate a client-driven property change.
107
+ *
108
+ * The re-render is the point: without it `html()` and every assertion that reads
109
+ * it would still describe the render from *before* the assignment, so a test
110
+ * would silently assert against stale markup. Use {@link seed} when you are
111
+ * assigning several properties and only want to pay for one render.
103
112
  * @category Actions
104
113
  */
105
114
  async set(prop: keyof T, value: unknown): Promise<this> {
106
115
  (this._page as Record<string, unknown>)[prop as string] = value;
116
+ await this._render();
117
+ return this;
118
+ }
119
+
120
+ /**
121
+ * Assign a property without re-rendering — the batching form of {@link set}.
122
+ * Nothing reads the new value until the next `set`/`update`/`call` (or an
123
+ * explicit {@link render}) produces a fresh render.
124
+ * @category Actions
125
+ *
126
+ * @example
127
+ * ```ts
128
+ * await t.seed('step', 3);
129
+ * await t.seed('mode', 'advanced');
130
+ * await t.render(); // one render for both
131
+ * ```
132
+ */
133
+ async seed(prop: keyof T, value: unknown): Promise<this> {
134
+ (this._page as Record<string, unknown>)[prop as string] = value;
135
+ return this;
136
+ }
137
+
138
+ /** Re-render the page without changing state (pairs with {@link seed}). @category Actions */
139
+ async render(): Promise<this> {
140
+ await this._render();
107
141
  return this;
108
142
  }
109
143
 
@@ -120,15 +154,71 @@ export class FlowTest<T extends Component> {
120
154
  return this;
121
155
  }
122
156
 
157
+ /**
158
+ * Opt out of the rethrow in {@link call}: a throwing action is captured on the
159
+ * harness instead of failing the test, so the error path itself can be asserted.
160
+ *
161
+ * Only for tests *about* error handling. Leaving it off is what makes a broken
162
+ * action fail its test rather than look like a no-op.
163
+ * @category Actions
164
+ *
165
+ * @example
166
+ * ```ts
167
+ * const t = (await FlowTest.mount(CheckoutPage)).tolerateErrors();
168
+ * await t.call('submit');
169
+ * t.assertErrored(/payment gateway/);
170
+ * ```
171
+ */
172
+ tolerateErrors(): this {
173
+ this._tolerateErrors = true;
174
+ return this;
175
+ }
176
+
177
+ /** The last non-validation error an action threw, or null. @category Actions */
178
+ lastError(): Error | null {
179
+ return this._lastError;
180
+ }
181
+
182
+ /**
183
+ * Assert the last action threw, optionally matching its message.
184
+ * Requires {@link tolerateErrors} — without it a throwing action fails the test outright.
185
+ * @category Assertions
186
+ */
187
+ assertErrored(match?: string | RegExp): this {
188
+ if (!this._lastError) {
189
+ throw new Error(
190
+ `[FlowTest] Expected the last action on ${this._name} to throw, but it did not.`,
191
+ );
192
+ }
193
+ if (match !== undefined) {
194
+ const message = this._lastError.message;
195
+ const ok = typeof match === "string" ? message.includes(match) : match.test(message);
196
+ if (!ok) {
197
+ throw new Error(
198
+ `[FlowTest] Expected the error message to match ${String(match)}, but got: ${message}`,
199
+ );
200
+ }
201
+ }
202
+ return this;
203
+ }
204
+
123
205
  /**
124
206
  * Call an action method and re-render. Simulates a subsequent (WebSocket) request:
125
207
  * runs `onBoot()` + `onHydrate()`, invokes the method, then `onUpdate()` and re-render.
126
- * If the action throws ValidationError, the errors are stored and the page re-renders
127
- * with the error bag populated; any other thrown error is routed to `onError()`.
208
+ *
209
+ * A `ValidationError` is an expected outcome: the errors are stored and the page
210
+ * re-renders with the error bag populated, exactly as in production.
211
+ *
212
+ * Any other error is routed to `onError()` (mirroring production) and then
213
+ * **rethrown**, so a broken action fails its test instead of silently rendering
214
+ * an unchanged page. Call {@link tolerateErrors} first when the error path is
215
+ * what you are testing.
128
216
  *
129
217
  * @param method - Name of the method to invoke on the page.
130
218
  * @param args - Arguments forwarded to the method.
131
219
  * @throws If `method` is not a function on the page instance.
220
+ * @throws Whatever the action threw, unless it was a `ValidationError` or
221
+ * {@link tolerateErrors} is on.
132
222
  * @category Actions
133
223
  */
134
224
  async call(method: string, ...args: unknown[]): Promise<this> {
@@ -141,16 +231,25 @@ export class FlowTest<T extends Component> {
141
231
  await this._page.onBoot();
142
232
  await this._page.onHydrate();
143
233
 
234
+ this._lastError = null;
235
+ let thrown: Error | null = null;
236
+
144
237
  try {
145
238
  await (fn as (...a: unknown[]) => unknown).apply(this._page, args);
146
239
  } catch (error) {
147
240
  if (error instanceof ValidationError) {
148
- // Errors already stored on page._errors
241
+ // Errors already stored on page._errors — a normal, assertable outcome.
149
242
  } else {
243
+ thrown = error instanceof Error ? error : new Error(String(error));
244
+ this._lastError = thrown;
245
+ // Run the production error hook so its side effects (the default flashes
246
+ // the message) are observable either way. An onError that itself throws
247
+ // replaces the original — surfacing the deeper bug rather than hiding it.
150
248
  try {
151
- await this._page.onError(error instanceof Error ? error : new Error(String(error)));
152
- } catch {
153
- // ignore
249
+ await this._page.onError(thrown);
250
+ } catch (hookError) {
251
+ thrown = hookError instanceof Error ? hookError : new Error(String(hookError));
252
+ this._lastError = thrown;
154
253
  }
155
254
  }
156
255
  }
@@ -158,6 +257,10 @@ export class FlowTest<T extends Component> {
158
257
  await this._page.onUpdate();
159
258
  this._effects = this._page._drainEffects();
160
259
  await this._render();
260
+
261
+ // Rethrow after the re-render, so an opted-in test still sees the rendered
262
+ // error state and a non-opted-in test gets the real stack.
263
+ if (thrown && !this._tolerateErrors) throw thrown;
161
264
  return this;
162
265
  }
163
266
 
package/src/utils.ts CHANGED
@@ -176,15 +176,46 @@ export interface FlowChain<T> {
176
176
  // Shared capture variable: set by instrumented @expose getters in jsx-runtime,
177
177
  // consumed immediately by flow() to embed __key in the chain.
178
178
  let _exposedKeyCapture: string | undefined;
179
+
180
+ /**
181
+ * Every exposed key read since the last consume, in read order.
182
+ *
183
+ * `_exposedKeyCapture` is a single slot, but JSX evaluates *all* of an element's
184
+ * props before `jsx()` runs — so `value={this.destination} disabled={this.notSure}`
185
+ * leaves the slot pointing at `notSure` and the `value` binding resolves to nothing.
186
+ * Keeping the full read list lets a binding find the key that actually matches its
187
+ * own value, while still refusing to bind a literal that was never read through a
188
+ * `this.` getter at all. Bounded so a pathological render cannot grow it without limit.
189
+ */
190
+ const _exposedKeyReads: string[] = [];
191
+ const _MAX_KEY_READS = 64;
192
+
179
193
  export function _setExposedKeyCapture(k: string | undefined): void {
180
194
  _exposedKeyCapture = k;
195
+ if (k === undefined) return;
196
+ // Keep the most recent read last; drop an earlier duplicate so order stays meaningful.
197
+ const at = _exposedKeyReads.indexOf(k);
198
+ if (at !== -1) _exposedKeyReads.splice(at, 1);
199
+ _exposedKeyReads.push(k);
200
+ if (_exposedKeyReads.length > _MAX_KEY_READS) _exposedKeyReads.shift();
181
201
  }
202
+
182
203
  export function _consumeExposedKeyCapture(): string | undefined {
183
204
  const k = _exposedKeyCapture;
184
205
  _exposedKeyCapture = undefined;
185
206
  return k;
186
207
  }
187
208
 
209
+ /**
210
+ * The keys read since the last call, most recent first, clearing the list.
211
+ * Used by the value/checked binding resolver to survive a clobbered single slot.
212
+ */
213
+ export function _consumeExposedKeyReads(): string[] {
214
+ const reads = _exposedKeyReads.slice().reverse();
215
+ _exposedKeyReads.length = 0;
216
+ return reads;
217
+ }
218
+
188
219
  export function flow<T>(target: T): FlowChain<T> {
189
220
  const modifiers: string[] = [];
190
221
  // Capture immediately so subsequent prop evaluations cannot clobber it.