@zerotal/flow 1.6.3 → 1.7.2

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
@@ -11,6 +11,187 @@ change.
11
11
 
12
12
  ## [Unreleased]
13
13
 
14
+ ### Added
15
+
16
+ - **`@on` can name a channel per instance.** A listener's channel is read off the *class*, so
17
+ a channel containing a record id could not be written at all. The guide showed
18
+ `@on("echo-private:issues.${this.issueId},CommentPosted")`, which is not interpolation —
19
+ it is a plain string, and the eleven characters `${this.issueId}` reached the browser
20
+ intact, subscribed to a channel nobody broadcasts to, and delivered nothing. No error, no
21
+ warning, no subscription: a live feature with this bug looks exactly like a live feature
22
+ that was never built.
23
+
24
+ `@on` now also accepts a resolver, the same shape `@presence` and `@shared` have always
25
+ taken and are resolved with four lines away in the same file:
26
+
27
+ ```ts
28
+ @on((self) => `echo-private:issues.${self.issue.id},CommentPosted`)
29
+ async onCommentPosted(payload: { comment: Comment }) { … }
30
+ ```
31
+
32
+ It is called with the component as the snapshot is built — after `onMount()`, so it can
33
+ read anything the component has loaded. A resolver that throws drops that one listener and
34
+ leaves the page rendering, because a page missing its live updates is degraded while a page
35
+ that 500s over an unnameable channel is gone. Static names are untouched.
36
+
37
+ Reach for the **narrowest** channel a reader is entitled to. A shared channel plus an
38
+ `if (payload.id !== this.id) return` in the handler looks equivalent and is not: the
39
+ broadcast still lands in every subscriber's browser, to be discarded after the fact.
40
+
41
+ - **`<Link down>` — prefetch on pointer-down.** The choice was between prefetching on hover
42
+ and not prefetching at all, and on a dense list neither is right. Hover prefetch is free
43
+ speed on a handful of stable links — a navigation rail, a breadcrumb — but the pointer
44
+ crosses every row between where it is and where it is going, so scrolling a hundred-row
45
+ table asks the server for a hundred pages nobody chose.
46
+
47
+ `down` fires on `pointerdown` with no dwell: once, on the link the reader has committed to,
48
+ and still ahead of the click by however long the button is held. `pointerdown` rather than
49
+ `mousedown` so a touch lands there too, and it is passive — the click that follows is
50
+ handled as usual and finds the page already cached. `flow:navigate.down` as an attribute;
51
+ both may be set.
52
+
53
+ ### Fixed
54
+
55
+ - **`<FileUpload>` never stopped saying "Uploading… 100%".** A successful upload left the
56
+ dropzone reporting progress forever. The bytes had gone up, the signed reference had come
57
+ back, and the bound property had been set — the only thing that failed was the component
58
+ admitting it, which meant the feature read as broken while working perfectly.
59
+
60
+ The bridge dispatches `flow:upload-finish` on success and the guide documents that name.
61
+ The component listened for `flow:upload-done`, which appeared nowhere else in the codebase
62
+ and has never been dispatched by anything. So `uploading` was set true on start and had no
63
+ path back to false. The component's own test covered start, progress and error, and skipped
64
+ success — which is how a one-word mismatch survived. It now covers it.
65
+
66
+ - **Signing out in another tab left the first tab looking broken.** The server has always
67
+ handled this: `hydrate()` refuses a snapshot whose subject no longer matches the session, and
68
+ the provider answers with an error frame carrying `reload: true` and "Your session changed —
69
+ reload the page." The client never read the flag. It reloaded for a dev soft-refresh and for
70
+ the literal message `"Unknown component"`, then fell through to `console.error` — so the
71
+ instruction reached a console nobody had open.
72
+
73
+ What the reader saw was a button that did nothing. The page stayed on screen, still looking
74
+ signed in, the action silently rejected, and no way to discover otherwise but to reload by
75
+ hand. The frame is now honoured, guarded by the same one-shot that protects the other reload
76
+ paths, and the decision is a pure `_reloadReasonFor()` so it can be tested without a DOM.
77
+ Matching on the flag rather than the sentence, so rewording or translating the message cannot
78
+ turn the recovery off.
79
+
80
+ - **Sibling subclasses of one decorated base stole each other's fields.** Field decorators buffer
81
+ their registration and the buffer is matched to a class lazily, on the first read of that class's
82
+ registry. Matching took the longest run across **every** buffered group, so a class could claim —
83
+ and splice away — an entry belonging to a sibling whose group merely shared a name.
84
+
85
+ Given `NewPage { project }` and `EditPage { project, issue }` over a shared base, draining
86
+ `NewPage` first found `project` at the head of `EditPage`'s group, tied on length against its own
87
+ single entry, and — ties going to the newest group — took it. `EditPage` then drained to `issue`
88
+ alone; its `project` was never registered, route-param seeding skipped the field, and `render()`
89
+ died on `this.project.slug`. Whether the page worked depended on which sibling had been rendered
90
+ first in that process, which is what made it look flaky rather than broken.
91
+
92
+ Matching is now restricted to groups whose every name is a field the class declares, falling back
93
+ to the old behaviour when nothing is fully contained. A sibling's group is rejected by the names
94
+ it has that this class does not.
95
+
96
+ - **The real-time validation example did not compile.** `docs/flow/decorators.md` showed
97
+ `<input value={this.email} flow:model.live />`. That is the *emitted* directive, not the prop:
98
+ the `.` in an attribute name is a parse error in TSX (`TS1003`), so anyone copying it got a
99
+ build failure on a line the guide had just handed them. The prop is `live`; the note now says
100
+ so, and says why the compiled form cannot be written by hand.
101
+
102
+ ### Documented
103
+
104
+ Four behaviours that were true but written down nowhere, each found by hitting it:
105
+
106
+ - **AOT compilation and `__()` are mutually exclusive** — a function call in a text child forces
107
+ the runtime fallback, so a translated app compiles no pages at all. Normally that is a speed
108
+ cost; under `cspSafe`, where every page must compile, it is a build failure. `performance.md`
109
+ now lists what blocks compilation and what to do about it.
110
+ - **`FlowTest` opens no request scope**, so `Auth.user()` and `Auth.attempt()` throw and no
111
+ action behind a sign-in can be driven without wrapping it. `testing.md` carries the wrapper.
112
+ - **A layout cannot set the document's `lang`** — Flow emits `<html lang="en">` and offers no
113
+ hook, so a localised app marks up its wrapper instead and the root element stays wrong.
114
+ - **`<Tabs>` selection is client-only and its strip is not themeable** — no bindable prop, so no
115
+ `?tab=`, no back button, and hardcoded gray/indigo that is invisible on a light surface.
116
+ - **Flow and `zerotal/view` components do not interoperate** — the two JSX runtimes produce
117
+ different element types (`HtmlNode` vs `SafeHtml`), so a view `FC` in a Flow page is `TS2786`.
118
+ - **`onSort` does not say which container took the drop** — the destination is encoded in which
119
+ method runs, so dragging between containers needs one action per container.
120
+
121
+ Also: `sortGroupId` is annotated as inert. It is declared, mapped to `flow:sort:group-id`, and
122
+ read by neither the runtime nor the compiler.
123
+
124
+ ## [1.7.1] — 2026-08-16
125
+
126
+ ### Changed
127
+
128
+ - **An action opens into its own tabs.** A frame's detail was a stack — Sent, then State,
129
+ then Server with the SQL buried under it. They are alternatives, not a sequence: you are
130
+ reading the arguments the click sent, _or_ the state it moved, _or_ the queries it ran. Now
131
+ a strip, matching the request rows: `Sent · State 2 · Server · Queries 3 · Logs 1`.
132
+
133
+ Queries and Logs are panes of their own, counted, so what an action cost the database is
134
+ one click from the frame rather than a list appended beneath a duration. Only the panes a
135
+ frame has appear, so the strip doubles as the summary, and which one you were reading is
136
+ kept as you step down the list — comparing the SQL of one action against the next should
137
+ not return you to the arguments each time.
138
+
139
+ - **One Flow tab in DevTools, and its rows open.** The time-travel timeline registered itself
140
+ as a tab called "Timeline", colliding with the panel's own Timeline tab, and sat apart from
141
+ the Flow tab even though the frames and the actions are the same clicks seen from the two
142
+ ends. They are now one tab, titled `Flow`.
143
+
144
+ Each frame expands to what that action actually did — the call and its arguments, the
145
+ fields it changed as `before → after`, and the server's own half: duration, status, client
146
+ IP, the queries it ran and the lines it logged, read off that action's devtools trace. A
147
+ frame that never left the browser says so rather than showing an empty server section.
148
+ Rewinding is unchanged: the row still time-travels, and the disclosure has its own hit area
149
+ so reading what happened does not also rewind the page you are reading it from.
150
+
151
+ Flow's action entries are recorded on the trace as before, now `hidden` so they no longer
152
+ claim a tab that repeated what the trace header already said.
153
+
154
+ ### Fixed
155
+
156
+ - **The DevTools Flow tab shows Flow actions.** It could not show any, ever: the tab
157
+ reported "No flow activity during this request" on every page of an app whose every
158
+ interaction is a Flow action.
159
+
160
+ An action arrives over the WebSocket and runs against its own `HttpContext`, and the
161
+ bridge recorded its entry against that context — correctly. But DevTools builds a trace
162
+ from core's `RequestHandled` / `RequestFailed`, and a socket action fires neither, so
163
+ that context became no trace and everything buffered against it was dropped unread. Not
164
+ only the Flow entry: the queries an action ran, its logs, its N+1 warnings. For a Flow
165
+ app that is the entire interactive half of the application, invisible in the one tool
166
+ built to see it.
167
+
168
+ The bridge now finalises the action's context itself, through the new
169
+ `TraceSink.finalise` in `@zerotal/devtools`. An action appears in the request list
170
+ labelled `FLOW` rather than as a second `GET` of the page it ran on, carrying the same
171
+ queries, logs and timings any request does. Found by wiring DevTools into this repo's
172
+ own `apps/docs` and driving a real action in a browser.
173
+
174
+ ## [1.7.0] — 2026-08-16
175
+
176
+ ### Fixed
177
+
178
+ - **SPA navigation no longer orphans the outgoing page's snapshot.** The swap removed the
179
+ first `[id^="flow-state-"]` in document order, but a page-level snapshot is a direct child
180
+ of `<body>` while a child island's sits inside the body content — so on any page with an
181
+ island it removed the island's script and left the page's behind. Ids are random per
182
+ request, so the leftovers accumulated one per navigation for as long as the tab stayed
183
+ open. Now scoped to `body > script[id^="flow-state-"]`, and every match is removed, so a
184
+ session that has already leaked cleans itself up on the next navigation.
185
+
186
+ ### Removed
187
+
188
+ - **`Component._skipMount`**, an `@internal` field that was written by `hydrate()` and
189
+ `restoreDurable()` and read by nothing. Mount-skipping is structural — the router branches
190
+ between `onHydrate()` and `onMount()` — and wiring the flag up would have been wrong:
191
+ `$refresh` and `$mount` deliberately re-run `onMount()` on a hydrated page. An inert flag
192
+ that reads as load-bearing is worse than none, so it is gone and the guarantee is pinned by
193
+ a test instead: mount runs exactly once across an initial render and any number of actions.
194
+
14
195
  ## [1.6.0] — 2026-08-15
15
196
 
16
197
  ### Added