@zerotal/flow 1.0.4 → 1.3.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,92 @@
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.3.0] — 2026-08-09
14
+
15
+ ### Fixed
16
+
17
+ - **An `@expose`d action on a shared page base no longer disappears from the allowlist.** Bun
18
+ 1.3.x does not reliably run a method decorator's `addInitializer` callbacks: when a subclass
19
+ declared a decorated field, the base class's method initializers never ran at all. So a page
20
+ base carrying `@expose guard()` lost that action the moment any subclass added one `@expose`
21
+ field — and since the un-`@expose`d action check is fatal, clicking a perfectly legitimate
22
+ button was rejected at runtime with a message pointing at the wrong cause. It reproduced with a
23
+ bare `class Sub extends Base` and no mixin anywhere. `@expose` (methods), `@task`,
24
+ `@renderless`, `@on` and `@computed` were all affected.
25
+
26
+ Method and getter decorators now tag the function in the decorator body — which always runs,
27
+ with the right name — and the readers scan the prototype chain for tagged members, registering
28
+ against the **declaring** prototype so inheritance resolves through the existing chain walk.
29
+ Registration no longer requires an instance to have been constructed, so `getExposedMethods()`
30
+ is correct on a class that has never been instantiated.
31
+
32
+ ### Changed — BREAKING
33
+
34
+ - **`ComponentWith(...)` is replaced by the `Component.using(...)` static.** Mixin composition is
35
+ now a property of the base class rather than a helper shipped alongside it, so there is one
36
+ idiom to learn and nothing extra to import.
37
+
38
+ ```tsx
39
+ // before
40
+ import { ComponentWith, Pagination } from "@zerotal/flow";
41
+ class PostsPage extends ComponentWith(Pagination, FileUploads) {}
42
+
43
+ // after
44
+ import { Component, Pagination } from "@zerotal/flow";
45
+ class PostsPage extends Component.using(Pagination, FileUploads) {}
46
+ ```
47
+
48
+ Run `bun run scripts/codemod-mixin-composition.ts` to rewrite call sites and imports.
49
+
50
+ How mixins are **authored** is unchanged — `<T extends Constructor<Component>>(Base: T) => …`
51
+ still works exactly as before, and every shipped mixin keeps its signature. The `Constructor`
52
+ and `Mixin` types are still exported; `Compose` (the type of `Component.using`) joins them.
53
+
54
+ ### Added
55
+
56
+ - **`using` composes onto any class in the chain, not just `Component`.** An app-level base can
57
+ now carry mixins without being flattened out of the prototype chain — `AdminPage.using(Pagination)`
58
+ keeps `AdminPage` in the lineage. `ComponentWith` hardcoded `Component`, so this previously
59
+ required hand-nesting (`Pagination(AdminPage)`).
60
+ - **Composition chains.** The composed class carries `using` itself, so
61
+ `Component.using(a, b).using(c, d)` works past the 8-mixin overload set.
62
+
63
+ ## [1.1.0] — 2026-08-08
64
+
65
+ ### Fixed
66
+
67
+ - **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.
68
+ - **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.
69
+ - **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)}`.
70
+ - **`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.
71
+ - **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`.
72
+ - **`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.
73
+
74
+ ### Added
75
+
76
+ - **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.
77
+ - `Component.bind(key, optionValue)` — the explicit way to bind one member of a radio group.
78
+ - `FlowTest.tolerateErrors()`, `assertErrored()`, `lastError()`, `seed()` and `render()`.
79
+
80
+ ### Changed
81
+
82
+ - 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.
83
+ - **`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.
84
+ - **`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.
85
+ - **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.
86
+ - **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.
87
+ - 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.
88
+ - 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`.
89
+
11
90
  ## [1.0.3] — 2026-08-07
12
91
 
13
92
  ### Changed
@@ -27,40 +106,40 @@ _First public release._
27
106
 
28
107
  ### Added
29
108
 
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.
109
+ - **`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.
110
+ - **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.
111
+ - **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.
112
+ - **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.
113
+ - **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`.
114
+
115
+ - **`@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.
116
+ - **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.
117
+ - **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.
118
+ - **`@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).
119
+ - **`@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
120
  - **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`).
121
+ - **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).
122
+ - **Named slots on child components.** `slots={{ … }}` + `this.slot(name)` / `this.hasSlot(name)`; slot HTML is carried, signed, in the child snapshot (`SnapshotMemo.slots`).
44
123
  - **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.
124
+ - **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.
125
+ - **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
126
 
48
127
  ### Changed
49
128
 
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.
129
+ - **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.
130
+ - **`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).
131
+ - **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
132
 
54
133
  ### Fixed
55
134
 
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).
135
+ - **`@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).
136
+ - **`@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.
137
+ - **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.
138
+ - **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.
139
+ - **`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.
140
+ - **`<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
141
  - **`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.
142
+ - **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
143
  - **`transition` prop.** Was typed but absent from `PULSE_PROP_MAP`, so it emitted an inert `transition` attribute instead of `flow:transition`.
65
144
  - **`children`/`slots` on components type-check.** Added to `JSX.IntrinsicAttributes` so passing children or a `slots` prop to a `Component` no longer errors.
66
145
 
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.
@@ -120,7 +122,7 @@ Action helpers available inside any exposed method include `this.flash()`, `this
120
122
 
121
123
  **Headless (unstyled, accessible) primitives:** `Switch`, `Checkbox`, `Select`, `RadioGroup`, `Listbox`, `Combobox`, `Disclosure`, `Accordion`, `Popover`, `Field`, `Label`, `Description`, `Fieldset`, `Legend`.
122
124
 
123
- **Mixins & helpers:** compose features with `ComponentWith(Pagination, FileUploads)` — the `Pagination` mixin (`page` state + `nextPage`/`gotoPage`/…, paired with `Model.paginate()` and rendered by `<Pager>`) and the `FileUploads` mixin (`removeUpload`, paired with `<FileUpload>` + `TemporaryUploadedFile`). Also `Form` + `registerForm`, the `paginate` helper + `Paginator` type, and `Layout` for shared page shells.
125
+ **Mixins & helpers:** compose features with `Component.using(Pagination, FileUploads)` — the `Pagination` mixin (`page` state + `nextPage`/`gotoPage`/…, paired with `Model.paginate()` and rendered by `<Pager>`) and the `FileUploads` mixin (`removeUpload`, paired with `<FileUpload>` + `TemporaryUploadedFile`). Also `Form` + `registerForm`, the `paginate` helper + `Paginator` type, and `Layout` for shared page shells.
124
126
 
125
127
  **Slots:** a child component's plain children are its default slot; a `slots={{ header, footer }}` prop supplies named slots. The child places them with `this.slot(name)` / `this.slot()` and branches on `this.hasSlot(name)` — ideal for reusable shells (cards, modals, panels). Slot markup renders in the parent's scope and is carried, signed, in the child's snapshot.
126
128
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/flow",
3
- "version": "1.0.4",
3
+ "version": "1.3.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.4",
49
- "@zerotal/orm": "1.0.4",
50
- "@zerotal/validator": "1.0.4",
48
+ "@zerotal/core": "1.3.0",
49
+ "@zerotal/orm": "1.3.0",
50
+ "@zerotal/validator": "1.3.0",
51
51
  "alpinejs": "^3.14.9",
52
52
  "typescript": "^5.8.0"
53
53
  },
54
54
  "devDependencies": {
55
- "@zerotal/broadcasting": "1.0.4",
55
+ "@zerotal/broadcasting": "1.3.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
@@ -23,6 +23,8 @@ import { ValidationError } from "./validation.ts";
23
23
  import type { ValidationRules } from "./validation.ts";
24
24
  import { resolveUploadValue } from "./uploads/TemporaryUploadedFile.ts";
25
25
  import { toScriptJson } from "./utils.ts";
26
+ import { _compose } from "./mixins.ts";
27
+ import type { Compose } from "./mixins.ts";
26
28
  import { _validateEventPayload } from "./events.ts";
27
29
  import type { FlowEvents, EventName, EventArgs } from "./events.ts";
28
30
  import { route, request, safeRedirectPath, HttpContext } from "@zerotal/core";
@@ -343,6 +345,54 @@ export abstract class Component {
343
345
  */
344
346
  static readonly __isFlowPage = true as const;
345
347
 
348
+ /**
349
+ * Compose one or more feature mixins onto this class, folding them left-to-right, so a page can
350
+ * be built from focused, reusable behaviours instead of one bloated class.
351
+ *
352
+ * @remarks
353
+ * Each mixin receives the accumulated base and returns an extended class, so `Component`'s full
354
+ * surface (`flash()`, `redirect()`, `validate()`, the client magics, …) and every mixin's
355
+ * `@expose`/`@locked` members flow through to the final page — fully type-checked. Mixin props
356
+ * register on the mixin prototype, which sits in the page's prototype chain, so snapshot,
357
+ * reactivity, client writes, and `@url` sync all work exactly as for props declared on the page.
358
+ *
359
+ * `using` composes onto whatever class it is called on, so it also works on an intermediate base
360
+ * (`AdminPage.using(Pagination)` keeps `AdminPage` in the chain), and the composed class carries
361
+ * `using` itself, so `Component.using(a, b).using(c)` chains past the 8-mixin overload set.
362
+ *
363
+ * Pages composed this way render via the runtime path rather than the AOT compiler (which only
364
+ * statically sees a page's own `extends Component` plus locally-declared members) — the same
365
+ * fallback complex pages already use; behaviour is identical, you just don't get the
366
+ * ahead-of-time compile step for that page.
367
+ *
368
+ * @param mixins - Mixin factories applied left-to-right (each takes the prior result as its base).
369
+ * @returns A class extending this one with every mixin's members mixed in.
370
+ *
371
+ * @example
372
+ * ```tsx
373
+ * // app/flow/mixins/pagination.ts
374
+ * import { Component, expose, type Constructor } from "@zerotal/flow";
375
+ *
376
+ * export function Pagination<T extends Constructor<Component>>(Base: T) {
377
+ * abstract class WithPagination extends Base {
378
+ * @expose page = 1;
379
+ * @expose next() { this.page++; }
380
+ * }
381
+ * return WithPagination;
382
+ * }
383
+ *
384
+ * // app/flow/UsersPage.tsx
385
+ * class UsersPage extends Component.using(Pagination) {
386
+ * override async render() {
387
+ * return <div data-flow-root>Page {this.page}</div>;
388
+ * }
389
+ * }
390
+ * ```
391
+ *
392
+ * @category Composition
393
+ */
394
+ static using: Compose = _compose;
395
+
346
396
  // ── @internal: framework bookkeeping, not part of the developer API ──────────
347
397
 
348
398
  /** Set to true by hydrate() so the framework skips calling onMount(). @internal */
@@ -1181,18 +1231,44 @@ export abstract class Component {
1181
1231
  /**
1182
1232
  * Emit the HTML attributes needed for `flow:model` two-way binding.
1183
1233
  *
1234
+ * Pass `optionValue` for one member of a radio group: the radio carries that
1235
+ * fixed value and is `checked` only when the bound property currently equals it.
1236
+ * A radio group is addressed as a unit — every option binds the same property —
1237
+ * so `value={…}`/`checked={…}` on a radio never infers a binding by itself and
1238
+ * this is the way to bind one.
1239
+ *
1184
1240
  * @example
1185
1241
  * <input {...this.bind('name')} />
1186
1242
  * // → <input flow:model="name" value="Alice" />
1187
1243
  *
1244
+ * @example
1245
+ * // A radio group over a `type` property:
1246
+ * {['CUSTOM', 'ROUTE', 'TEAMS'].map((t) => (
1247
+ * <label>
1248
+ * <input type="radio" name="type" {...this.bind('type', t)} /> {t}
1249
+ * </label>
1250
+ * ))}
1251
+ * // → <input type="radio" name="type" flow:model="type" value="ROUTE" checked>
1252
+ *
1188
1253
  * @param key the `@expose` property to two-way bind.
1189
- * @returns the `flow:model` + `value` attributes to spread onto an input.
1254
+ * @param optionValue this radio's own value; omit for text/checkbox/select inputs.
1255
+ * @returns the `flow:model` + `value` (+ `checked`) attributes to spread onto an input.
1190
1256
  * @category State & exposure
1191
1257
  */
1192
- bind(key: string): Record<string, unknown> {
1258
+ bind(key: string, optionValue?: string): Record<string, unknown> {
1259
+ const current = (this as Record<string, unknown>)[key];
1260
+ if (optionValue !== undefined) {
1261
+ return {
1262
+ "flow:model": key,
1263
+ value: optionValue,
1264
+ // String-compare so a numeric property still matches its string option value —
1265
+ // the DOM only ever gives a radio's value back as a string.
1266
+ checked: String(current ?? "") === optionValue,
1267
+ };
1268
+ }
1193
1269
  return {
1194
1270
  "flow:model": key,
1195
- value: (this as Record<string, unknown>)[key] ?? "",
1271
+ value: current ?? "",
1196
1272
  };
1197
1273
  }
1198
1274
 
@@ -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;