@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 +317 -4
- package/README.md +1 -1
- package/package.json +8 -9
- package/src/Component.ts +214 -41
- package/src/browser/FlowBrowser.ts +270 -0
- package/src/browser/cdp.ts +273 -0
- package/src/browser/index.ts +19 -0
- package/src/client/FlowComponent.ts +1 -1
- package/src/client/bridge.ts +345 -38
- package/src/client/headless.ts +2 -1
- package/src/compiler/directives.ts +1 -0
- package/src/compiler/transform.ts +4 -0
- package/src/components.ts +286 -4
- package/src/config.ts +2 -2
- package/src/decorators.ts +122 -35
- package/src/dehydrate.ts +2 -2
- package/src/events.ts +1 -1
- package/src/index.ts +8 -2
- package/src/jsx-runtime.ts +90 -7
- package/src/pagination.ts +5 -7
- package/src/provider/FlowProvider.ts +46 -13
- package/src/router.ts +186 -116
- package/src/sections.ts +121 -0
- package/src/store.ts +0 -1
- package/src/streaming.ts +92 -0
- package/src/synths/index.ts +2 -2
- package/src/testing.ts +1 -1
- package/src/types.ts +1 -1
- package/src/uploads/FileUploads.ts +3 -4
- package/src/uploads/TemporaryUploadedFile.ts +1 -1
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: `
|
|
8
|
-
|
|
9
|
-
|
|
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
|
|
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:
|
|
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
|
+
"version": "1.5.0",
|
|
4
4
|
"license": "MIT",
|
|
5
|
-
"maturity": "
|
|
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.
|
|
49
|
-
"@zerotal/orm": "1.
|
|
50
|
-
"@zerotal/validator": "1.
|
|
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.
|
|
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": {
|