@zerotal/flow 1.3.0 → 1.5.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
@@ -4,12 +4,325 @@ 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: `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.
7
+ **Maturity: `stable`** — matching this package's `maturity` field. The public API
8
+ follows SemVer strictly: anything importable without an `@internal` marker keeps its
9
+ shape for the rest of the 1.x line, and `api-surface.md` is diffed by CI on every
10
+ change.
10
11
 
11
12
  ## [Unreleased]
12
13
 
14
+ ## [1.5.0] — 2026-08-15
15
+
16
+ ### Changed — BREAKING
17
+
18
+ - **`redirectRoute(name, params)` takes route params only.** Extra keys used to become
19
+ query-string entries; core's `route()` now treats a key that matches no `:segment` as
20
+ the mistake it usually is and throws, naming it. Route names and params are checked at
21
+ compile time once the app has run `bun zt route:types`. For a redirect that needs a
22
+ query string, build the URL: `this.redirect(route("posts.show", { slug }, { ref }))`.
23
+
24
+ ### Changed
25
+
26
+ - **The client now says which transport failure it hit.** `[Flow] WebSocket unavailable —
27
+ falling back to HTTP requests` was the same line whether the handshake was blocked by a
28
+ proxy, refused by the origin guard, or genuinely unreachable, and the HTTP fallback then
29
+ logged a bare `HTTP 403` that dropped both the status and the server's own explanation.
30
+
31
+ A `403` from `/__flow/http` is now reported once, in full, naming the page's origin and
32
+ the config that fixes it — and the connection state drops to **offline**. That is the
33
+ honest state: the origin guard will refuse every retry, so actions queue behind
34
+ `flow:offline` directives instead of being fired at an endpoint that cannot accept them.
35
+ A refused origin was the one failure the fallback could see and was reporting as a
36
+ transient degrade.
37
+
38
+ - **Flow no longer rebuilds its CSS/JS bundles at boot in production when `public/` is
39
+ read-only.** Same policy as `serve`; see `@zerotal/core`. Build them with
40
+ `bun zt assets:build` as a release step and the service needs no write access to its own
41
+ output tree.
42
+
43
+ - **`/__flow/ws` is declared at registration**, so a CLI process can name it. That is what
44
+ lets `bun zt doctor --url=…` probe the socket through the real proxy — the check that
45
+ catches an origin guard or an auth gate over the transport before a user does.
46
+
47
+ ### Fixed
48
+
49
+ - **A decorator could be registered against the wrong component.** Field decorators
50
+ cannot see their own class, so each registration is buffered in the decorator body
51
+ and matched to a class later. The match searched the whole buffer by field name, and
52
+ a component that declares a field and is never rendered leaves its entry buffered for
53
+ the life of the process — so an unrelated component with a field of the same name
54
+ could claim it, receive a decorator it never asked for, and never receive its own.
55
+ `page`, `count` and `open` are declared in a lot of files.
56
+
57
+ Two things were wrong. The search now runs per declaring class rather than across
58
+ one flat buffer, and a class is matched on the fields it **declares** rather than
59
+ every field on an instance — a subclass inherits its mixin's fields, so a leftover
60
+ block from another application of that mixin used to out-score the subclass's own.
61
+
62
+ It surfaced as `@reactive` failing to register: the prop was then folded into the
63
+ child's id, so every parent-pushed change remounted the child instead of updating it
64
+ in place — the exact thing `@reactive` exists to prevent. Whether it happened
65
+ depended on which modules had loaded first, which is why it reproduced on one machine
66
+ and not another.
67
+
68
+ - **A keyless child in a list is no longer identified by its position.** The id was
69
+ `<parentId>-<name>-<occurrence index>`, and an index is _position_, not identity:
70
+ remove an item from a list and every row below it inherits the id its neighbour had.
71
+ Because a hydrated parent emits an already-known child as an **empty stub** — on the
72
+ understanding that the client morph will recognise the pairing — every row then
73
+ adopted the previous row's live DOM and the last was left blank. Nothing warned, and
74
+ nothing server-side could see it: SSR, snapshot assertions and `FlowTest` all render
75
+ the full child every time, because they never take the already-mounted branch.
76
+
77
+ The id is now derived from the child's **seed props** plus an occurrence counter, so
78
+ the pairing is content-addressed and a shifted row is rendered in full rather than
79
+ crossed with its neighbour. `@reactive` and `@modelable` props are deliberately
80
+ excluded — those exist to change _without_ remounting the child, and hashing them
81
+ would trade this bug for a child that resets on every parent update.
82
+
83
+ Two siblings whose props are identical are still, as far as the framework can see,
84
+ the same child. Flow now logs a warning the first time it sees that in development,
85
+ naming the class and the fix. `key` remains the answer, and the docs say so where
86
+ `child()` is documented.
87
+
88
+ ### Added
89
+
90
+ - **`<ErrorBoundary>` — a failing child costs that child, not the page.** A nested
91
+ component that threw while mounting or rendering took the whole response with it: one
92
+ broken widget blanked the dashboard, and the only defence was to make every child
93
+ defensive from the inside. Wrapped, the child is replaced and everything around it
94
+ renders:
95
+
96
+ ```tsx
97
+ <ErrorBoundary fallback={<p class="text-sm text-red-600">Sales data unavailable.</p>}>
98
+ <SalesReport />
99
+ </ErrorBoundary>
100
+ ```
101
+
102
+ `fallback` may be a function, which receives the thrown error; `onError` reports it to a
103
+ log or a tracker without changing what renders. Boundaries nest and the innermost one
104
+ wins, and siblings are independent — one failing widget does not affect the other.
105
+
106
+ What it covers is child _components_.
107
+ Inline JSX in the same `render()` is evaluated before the boundary is ever called, so a
108
+ throw there cannot be intercepted here — move the risky work into a child component,
109
+ which is where it belongs anyway. Containment is opt-in for the same reason: a child
110
+ outside every boundary still fails the page, so a real bug surfaces instead of rendering
111
+ as blank space forever.
112
+
113
+ - **`stream` — a slow child no longer holds up the shell.** Mark a child
114
+ `<SalesReport stream />` and the page paints immediately with that child's placeholder;
115
+ the real markup is appended to the _same_ response as it finishes rendering. The browser
116
+ receives a `<template>` carrying the finished markup and a one-line script that swaps it
117
+ in, which runs during parse — so the content appears without a second request, without
118
+ the socket, and without waiting for (or even having) the runtime.
119
+
120
+ This is the missing third option next to `lazy` and `defer`. Both of those also paint a
121
+ placeholder first, but the real render happens on a second round trip over the socket,
122
+ which is right for content that may never be needed — a widget below the fold, a tab
123
+ nobody opens. `stream` is for content that is definitely needed and merely slow, where
124
+ the only thing a round trip buys is latency.
125
+
126
+ A child that fails mid-stream is replaced with a notice rather than failing the
127
+ response: the shell is already on the wire by then, so there is nothing left to fail.
128
+ Streaming applies to the initial `GET` only — over the socket there is no open response
129
+ to append to, so `stream` degrades to an ordinary inline child. Flow sets
130
+ `X-Accel-Buffering: no`; behind a proxy that buffers anyway the browser simply receives
131
+ the whole document at once, which is correct but not progressive.
132
+
133
+ - **`<SectionContent>` / `<SectionOutlet>` — a page can fill a region the layout owns.**
134
+ Putting a page-specific button in the layout's toolbar meant threading it through every
135
+ component in between as props, or the layout knowing about every page that might
136
+ contribute. Sections invert that: the component that owns the content declares it, and
137
+ the layout declares a hole.
138
+
139
+ ```tsx
140
+ // In the layout
141
+ <SectionOutlet name="toolbar" />
142
+
143
+ // In any page — renders nothing here
144
+ <SectionContent name="toolbar">
145
+ <button onClick={this.publish}>Publish</button>
146
+ </SectionContent>
147
+ ```
148
+
149
+ Order does not matter. An outlet emits a token and is filled in a final pass over the
150
+ finished document rather than reading a store when it renders — which is what makes the
151
+ usual arrangement work at all, since the layout wraps a page that has already rendered.
152
+ Two components may publish to one name and their content accumulates in render order.
153
+ Outlet children are the default, used when nothing was published.
154
+
155
+ Sections resolve once per document render: a WebSocket patch re-renders a component, not
156
+ the layout, so content published during one does not reach an outlet outside the
157
+ component being patched.
158
+
159
+ - **`<Virtualize>` — a scrolling window over a collection too large for the DOM.** Only
160
+ the visible rows exist as elements; spacers above and below hold the scrollbar at the
161
+ size the full collection implies. As the viewport moves, `onWindow(start, count)` asks
162
+ the server for the window it now needs, so the collection never has to reach the client
163
+ in full — the server-authoritative arrangement, with an
164
+ `ItemsProvider`.
165
+
166
+ ```tsx
167
+ <Virtualize
168
+ items={this.rows}
169
+ start={this.windowStart}
170
+ total={this.total}
171
+ itemHeight={36}
172
+ height={480}
173
+ onWindow={this.loadWindow}
174
+ >
175
+ {(row) => <div class="h-9 px-3 leading-9">{row.name}</div>}
176
+ </Virtualize>
177
+ ```
178
+
179
+ Rows must be a uniform `itemHeight` — that is what lets a scroll offset become an index
180
+ without measuring anything. `overscan` (default 6) renders extra rows on each side to
181
+ hide fetch latency, and a window request is only made when the needed window actually
182
+ changes, since scroll fires far more often than that. Distinct from `<InfiniteScroll>`,
183
+ which appends and grows the DOM without bound; reach for this when _keeping_ every
184
+ rendered row is the problem.
185
+
186
+ - **`@zerotal/flow/browser` — drive a real browser against a running app.** The suite
187
+ had 568 tests and could not fail the way production fails: `FlowTest` calls actions
188
+ directly, so it never renders an attribute the client must find, never dispatches a
189
+ DOM event, and never opens the socket. Every silent failure Flow has shipped lived in
190
+ exactly that gap — a `<select>` that lost its binding, a click cancelled by
191
+ `preventDefault`, an action the server refused and mentioned only to `console.error`.
192
+ Depth was never the problem; shape was.
193
+
194
+ `FlowBrowser` drives headless Chrome over the DevTools Protocol, so a click is a real
195
+ click travelling through the page's own delegated listener, over a real socket, to the
196
+ real dispatcher, and back as a real patch:
197
+
198
+ ```ts
199
+ const page = await FlowBrowser.open(`http://localhost:${port}/counter`);
200
+ await page.click("#increment");
201
+ await page.waitForText("#count", "1");
202
+ expect(page.consoleErrors()).toEqual([]);
203
+ ```
204
+
205
+ Console output is captured deliberately rather than incidentally: a refused action is
206
+ reported _only_ there, so a harness that cannot see the console cannot see the
207
+ failure. Waits are polls with timeouts that name what they were waiting for and what
208
+ the console said, so a break reads as a diagnosis instead of a mystery.
209
+
210
+ No Puppeteer, no Playwright — it speaks CDP over Bun's own WebSocket and drives a
211
+ browser the machine already has, matching the zero-dependency posture of the telemetry
212
+ tracer and the media image driver. `FlowBrowser.available()` lets a suite skip where no
213
+ browser is installed rather than fail; `CHROME_PATH` pins a binary for CI.
214
+
215
+ - **A compiled-versus-runtime parity suite.** Flow renders a page one of two ways, and
216
+ when the AOT compiler bails the runtime renderer infers bindings by observing property
217
+ reads instead of reading intent from the AST — necessarily weaker, and the gap between
218
+ them is where the field reports kept finding silent failures. "Weaker mechanism" is not
219
+ the same claim as "different output", so the output is now pinned: each case is written
220
+ once, compiled _and_ imported as a real class, and both paths must emit the same
221
+ bindings. They agree across the corpus, including the two shapes that were reported as
222
+ broken. A case the compiler bails on still has to carry its binding through the
223
+ fallback.
224
+
225
+ ### Fixed
226
+
227
+ - **`data-flow-connection` is stamped on a page that connects normally.** The attribute
228
+ was only written when the connection state _changed_, and the bridge starts optimistic
229
+ (`_isOnline = true`), so a socket that connected on the first attempt — the normal case
230
+ — left the attribute absent entirely. A stylesheet or a readiness check keyed on
231
+ `[data-flow-connection="online"]` would wait forever on a page that was in fact
232
+ perfectly connected. Found by the browser harness on its first run, which is a fair
233
+ summary of why the harness exists.
234
+
235
+ ### Changed
236
+
237
+ - **Maturity is now `stable`** — the public API follows SemVer strictly for the rest of
238
+ the 1.x line. Both gates that stood in the way are closed in this release, and neither
239
+ was closed by relabelling:
240
+
241
+ - The **bridge is covered by a real browser**, so the layer that produced every silent
242
+ failure Flow has shipped is now exercised by tests that fail when it breaks. The gap
243
+ was never depth — it was that 568 tests all stopped short of the socket.
244
+ - The **two render paths are pinned to the same output**, so the "compiled is stronger
245
+ than runtime" caveat is now a statement about mechanism rather than an unmeasured
246
+ behavioural risk.
247
+
248
+ The supporting evidence: the deepest test coverage in the monorepo, correct
249
+ `@internal` discipline across 61 markers, thirteen documentation pages, dependencies
250
+ only on stable packages, and an API surface snapshotted and CI-diffed on every change.
251
+ The one breaking change Flow has ever made was 1.3.0's mixin-composition rename; 1.4.0
252
+ and this release both followed it without one.
253
+
254
+ Being honest about what the label does not claim: Flow has less production exposure
255
+ than the core and ORM layers, and the parity suite characterises a corpus of binding
256
+ shapes rather than proving the two renderers identical for every program. What
257
+ `stable` commits to is that the API will not move under you, and that is a promise
258
+ this release can keep.
259
+
260
+ - **A client expression that writes an `@expose` prop now syncs to the server.**
261
+ `onClick={() => (this.selected = row.id)}` updated the reactive store and nothing
262
+ else — `render()` runs on the server, so a template branch keyed on the write
263
+ (`this.selected === row.id ? <RowDetail /> : null`) never appeared, with no error
264
+ anywhere. Worse, it _worked_ whenever some later action happened to flush the
265
+ pending write, so the mental model "`@expose` state is reactive" was true for
266
+ `flow:model` inputs and intermittently false for expression writes.
267
+
268
+ Writes made during a client-expression evaluation are now recorded; when the
269
+ expression dispatches no action on that component itself, one `$rerender` follows —
270
+ the frame's `updates` carry every pending write through the same allowlist and
271
+ `updating()`/`updated()` hooks as any action, and the re-render is patched back.
272
+ No loading state is shown (it is a sync, not a user action), an expression that
273
+ also calls a server action syncs nothing extra (the action's frame already carries
274
+ the writes), and a write that lands back on the canonical value sends nothing.
275
+ State the server should never see belongs in `this.store()`, which stays
276
+ client-only. `$flow.$refresh()` remains for forcing a full `onMount` re-run.
277
+
278
+ ### Fixed
279
+
280
+ - **The dev timeline pill docks bottom-left, as its own comment always said.** The CSS
281
+ put it at `top: 88px` at maximum z-index — exactly where a left-rail's first
282
+ navigation item lives, which it then covered and swallowed clicks for. It now sits at
283
+ bottom-left with the frame list opening upward, and the corner is configurable via
284
+ `data-flow-tl-corner="top-right"` (any corner) on `<html>` or `<body>`. Only affects
285
+ the standalone fallback pill; the devtools-panel Timeline tab is unchanged.
286
+
287
+ ## [1.4.0] — 2026-08-10
288
+
289
+ ### Fixed
290
+
291
+ - **`focusOnError` did nothing on a page that rendered through the runtime.** The
292
+ JSX runtime rewrites `-` to `.` inside a `flow:` attribute — right for a
293
+ hand-written `flow:loading-class`, wrong for a name the directive map already
294
+ spells out. `focusOnError` reached the DOM as `flow:focus.error` while the
295
+ bridge only ever queries `[flow\:focus-error]`, so focus-on-error worked on a
296
+ compiled page and silently did not on one the compiler bailed out of (a
297
+ branching `render()`, say). Mapped names now go out verbatim; the kebab→dot
298
+ rewrite still applies to attributes written by hand. `sortGroupId` was affected
299
+ the same way.
300
+
301
+ - **`flow:navigate` now scrolls, instead of leaving you wherever the last page
302
+ was.** The SPA swap replaced the page under a stationary viewport and never
303
+ touched the scroll offset, so following a link from near the bottom of a long
304
+ list landed you halfway down the next page. Nothing visible changed, which
305
+ reads as the page having failed to load rather than as a scroll problem.
306
+
307
+ A navigation now lands at the top of the new page, or at the fragment when the
308
+ href names one (`/docs#install`) — which is what the same link does without the
309
+ SPA swap. Back and Forward restore the position the entry was left at.
310
+
311
+ The scroll happens inside the swap, after the root is replaced: the document
312
+ only has its new height (and its fragment targets) at that point, and inside a
313
+ View Transition it is also what the "after" snapshot captures, so the animation
314
+ ends at the right offset rather than sliding to it. It is issued with
315
+ `behavior: "instant"`, so a page whose CSS sets `scroll-behavior: smooth` does
316
+ not animate the whole way up on every visit.
317
+
318
+ ### Added
319
+
320
+ - **`preserveScroll`** — `<Link href="…" preserveScroll>` and
321
+ `this.navigateCurrent({ query: …, preserveScroll: true })` leave the viewport
322
+ where it is. For a control partway down a page — a sort header, a filter, a tab
323
+ strip — going to the top loses the thing the user was just looking at. Compiles
324
+ to `flow:navigate.preserve` on the anchor.
325
+
13
326
  ## [1.3.0] — 2026-08-09
14
327
 
15
328
  ### Fixed
@@ -127,7 +440,7 @@ _First public release._
127
440
  ### Changed
128
441
 
129
442
  - **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).
443
+ - **`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, 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
444
  - **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.
132
445
 
133
446
  ### Fixed
package/README.md CHANGED
@@ -2,7 +2,7 @@
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.
5
+ **Maturity: stable.** Flow's public API follows SemVer strictly: anything importable without an `@internal` marker keeps its shape for the rest of the 1.x line, and its surface is snapshotted in `api-surface.md`, which CI diffs on every change. The WebSocket bridge is covered end to end by a real browser (`@zerotal/flow/browser`), and the compiled and runtime render paths are pinned to the same binding output. It carries less production exposure than the core and ORM layers, so read the release notes — but the API will not move under you.
6
6
 
7
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.
8
8
 
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@zerotal/flow",
3
- "version": "1.3.0",
3
+ "version": "1.5.0",
4
4
  "license": "MIT",
5
- "maturity": "experimental",
5
+ "maturity": "stable",
6
6
  "private": false,
7
7
  "type": "module",
8
8
  "main": "./src/index.ts",
@@ -13,7 +13,8 @@
13
13
  "./jsx-dev-runtime": "./src/jsx-dev-runtime.ts",
14
14
  "./compiler-helpers": "./src/compiler/helpers.ts",
15
15
  "./store": "./src/store.ts",
16
- "./testing": "./src/testing.ts"
16
+ "./testing": "./src/testing.ts",
17
+ "./browser": "./src/browser/index.ts"
17
18
  },
18
19
  "files": [
19
20
  "CHANGELOG.md",
@@ -45,14 +46,14 @@
45
46
  "@alpinejs/morph": "^3.14.9",
46
47
  "@alpinejs/persist": "^3.14.9",
47
48
  "@alpinejs/resize": "^3.14.9",
48
- "@zerotal/core": "1.3.0",
49
- "@zerotal/orm": "1.3.0",
50
- "@zerotal/validator": "1.3.0",
49
+ "@zerotal/core": "1.5.0",
50
+ "@zerotal/orm": "1.5.0",
51
+ "@zerotal/validator": "1.5.0",
51
52
  "alpinejs": "^3.14.9",
52
53
  "typescript": "^5.8.0"
53
54
  },
54
55
  "devDependencies": {
55
- "@zerotal/broadcasting": "1.3.0",
56
+ "@zerotal/broadcasting": "1.5.0",
56
57
  "@types/alpinejs": "^3.13.10",
57
58
  "@types/alpinejs__anchor": "^3.13.1",
58
59
  "@types/alpinejs__collapse": "^3.13.4",
@@ -64,7 +65,6 @@
64
65
  },
65
66
  "description": "Flow (experimental) — server-driven reactive UI for Zerotal with signed snapshots, delta transport, and real-time primitives.",
66
67
  "keywords": [
67
- "experimental",
68
68
  "zerotal",
69
69
  "bun",
70
70
  "typescript",
@@ -72,7 +72,6 @@
72
72
  "reactive",
73
73
  "ssr",
74
74
  "websocket",
75
- "livewire",
76
75
  "server-driven"
77
76
  ],
78
77
  "repository": {