@zerotal/flow 1.7.0 → 1.7.3

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
@@ -9,6 +9,182 @@ follows SemVer strictly: anything importable without an `@internal` marker keeps
9
9
  shape for the rest of the 1.x line, and `api-surface.md` is diffed by CI on every
10
10
  change.
11
11
 
12
+ ## [Unreleased]
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
+ - **A bound password field silently discarded every keystroke.** The client-writable set was
56
+ `fillable` minus `hidden`, which conflates two allow-lists that answer different questions:
57
+ `fillable`/`guarded` govern what may be _written_, `visible`/`hidden` govern what may be
58
+ _shown_. A password is in both — fillable because a user sets it, hidden because the stored
59
+ hash must never reach a page — so subtracting made it unwritable, and
60
+ `<input type="password" value={this.user.password} blur />` accepted typing and dropped it
61
+ on arrival. `hidden` is no longer subtracted. It is still never _sent_: the hash does not
62
+ leave the server, and the field arrives empty as it should. A hidden value the client
63
+ supplied now survives until save, and one the _server_ produced is never echoed back.
64
+
65
+ A half-typed secret is also stripped from the [durable snapshot](/docs/flow/lifecycle)
66
+ before it is persisted — that store is written server-side after every request and may be
67
+ backed by Redis, which is no place for a password in progress.
68
+
69
+ - **`<FileUpload>` never stopped saying "Uploading… 100%".** A successful upload left the
70
+ dropzone reporting progress forever. The bytes had gone up, the signed reference had come
71
+ back, and the bound property had been set — the only thing that failed was the component
72
+ admitting it, which meant the feature read as broken while working perfectly.
73
+
74
+ The bridge dispatches `flow:upload-finish` on success and the guide documents that name.
75
+ The component listened for `flow:upload-done`, which appeared nowhere else in the codebase
76
+ and has never been dispatched by anything. So `uploading` was set true on start and had no
77
+ path back to false. The component's own test covered start, progress and error, and skipped
78
+ success — which is how a one-word mismatch survived. It now covers it.
79
+
80
+ - **Signing out in another tab left the first tab looking broken.** The server has always
81
+ handled this: `hydrate()` refuses a snapshot whose subject no longer matches the session, and
82
+ the provider answers with an error frame carrying `reload: true` and "Your session changed —
83
+ reload the page." The client never read the flag. It reloaded for a dev soft-refresh and for
84
+ the literal message `"Unknown component"`, then fell through to `console.error` — so the
85
+ instruction reached a console nobody had open.
86
+
87
+ What the reader saw was a button that did nothing. The page stayed on screen, still looking
88
+ signed in, the action silently rejected, and no way to discover otherwise but to reload by
89
+ hand. The frame is now honoured, guarded by the same one-shot that protects the other reload
90
+ paths, and the decision is a pure `_reloadReasonFor()` so it can be tested without a DOM.
91
+ Matching on the flag rather than the sentence, so rewording or translating the message cannot
92
+ turn the recovery off.
93
+
94
+ - **Sibling subclasses of one decorated base stole each other's fields.** Field decorators buffer
95
+ their registration and the buffer is matched to a class lazily, on the first read of that class's
96
+ registry. Matching took the longest run across **every** buffered group, so a class could claim —
97
+ and splice away — an entry belonging to a sibling whose group merely shared a name.
98
+
99
+ Given `NewPage { project }` and `EditPage { project, issue }` over a shared base, draining
100
+ `NewPage` first found `project` at the head of `EditPage`'s group, tied on length against its own
101
+ single entry, and — ties going to the newest group — took it. `EditPage` then drained to `issue`
102
+ alone; its `project` was never registered, route-param seeding skipped the field, and `render()`
103
+ died on `this.project.slug`. Whether the page worked depended on which sibling had been rendered
104
+ first in that process, which is what made it look flaky rather than broken.
105
+
106
+ Matching is now restricted to groups whose every name is a field the class declares, falling back
107
+ to the old behaviour when nothing is fully contained. A sibling's group is rejected by the names
108
+ it has that this class does not.
109
+
110
+ - **The real-time validation example did not compile.** `docs/flow/decorators.md` showed
111
+ `<input value={this.email} flow:model.live />`. That is the _emitted_ directive, not the prop:
112
+ the `.` in an attribute name is a parse error in TSX (`TS1003`), so anyone copying it got a
113
+ build failure on a line the guide had just handed them. The prop is `live`; the note now says
114
+ so, and says why the compiled form cannot be written by hand.
115
+
116
+ ### Documented
117
+
118
+ Four behaviours that were true but written down nowhere, each found by hitting it:
119
+
120
+ - **AOT compilation and `__()` are mutually exclusive** — a function call in a text child forces
121
+ the runtime fallback, so a translated app compiles no pages at all. Normally that is a speed
122
+ cost; under `cspSafe`, where every page must compile, it is a build failure. `performance.md`
123
+ now lists what blocks compilation and what to do about it.
124
+ - **`FlowTest` opens no request scope**, so `Auth.user()` and `Auth.attempt()` throw and no
125
+ action behind a sign-in can be driven without wrapping it. `testing.md` carries the wrapper.
126
+ - **A layout cannot set the document's `lang`** — Flow emits `<html lang="en">` and offers no
127
+ hook, so a localised app marks up its wrapper instead and the root element stays wrong.
128
+ - **`<Tabs>` selection is client-only and its strip is not themeable** — no bindable prop, so no
129
+ `?tab=`, no back button, and hardcoded gray/indigo that is invisible on a light surface.
130
+ - **Flow and `zerotal/view` components do not interoperate** — the two JSX runtimes produce
131
+ different element types (`HtmlNode` vs `SafeHtml`), so a view `FC` in a Flow page is `TS2786`.
132
+ - **`onSort` does not say which container took the drop** — the destination is encoded in which
133
+ method runs, so dragging between containers needs one action per container.
134
+
135
+ Also: `sortGroupId` is annotated as inert. It is declared, mapped to `flow:sort:group-id`, and
136
+ read by neither the runtime nor the compiler.
137
+
138
+ ## [1.7.1] — 2026-08-16
139
+
140
+ ### Changed
141
+
142
+ - **An action opens into its own tabs.** A frame's detail was a stack — Sent, then State,
143
+ then Server with the SQL buried under it. They are alternatives, not a sequence: you are
144
+ reading the arguments the click sent, _or_ the state it moved, _or_ the queries it ran. Now
145
+ a strip, matching the request rows: `Sent · State 2 · Server · Queries 3 · Logs 1`.
146
+
147
+ Queries and Logs are panes of their own, counted, so what an action cost the database is
148
+ one click from the frame rather than a list appended beneath a duration. Only the panes a
149
+ frame has appear, so the strip doubles as the summary, and which one you were reading is
150
+ kept as you step down the list — comparing the SQL of one action against the next should
151
+ not return you to the arguments each time.
152
+
153
+ - **One Flow tab in DevTools, and its rows open.** The time-travel timeline registered itself
154
+ as a tab called "Timeline", colliding with the panel's own Timeline tab, and sat apart from
155
+ the Flow tab even though the frames and the actions are the same clicks seen from the two
156
+ ends. They are now one tab, titled `Flow`.
157
+
158
+ Each frame expands to what that action actually did — the call and its arguments, the
159
+ fields it changed as `before → after`, and the server's own half: duration, status, client
160
+ IP, the queries it ran and the lines it logged, read off that action's devtools trace. A
161
+ frame that never left the browser says so rather than showing an empty server section.
162
+ Rewinding is unchanged: the row still time-travels, and the disclosure has its own hit area
163
+ so reading what happened does not also rewind the page you are reading it from.
164
+
165
+ Flow's action entries are recorded on the trace as before, now `hidden` so they no longer
166
+ claim a tab that repeated what the trace header already said.
167
+
168
+ ### Fixed
169
+
170
+ - **The DevTools Flow tab shows Flow actions.** It could not show any, ever: the tab
171
+ reported "No flow activity during this request" on every page of an app whose every
172
+ interaction is a Flow action.
173
+
174
+ An action arrives over the WebSocket and runs against its own `HttpContext`, and the
175
+ bridge recorded its entry against that context — correctly. But DevTools builds a trace
176
+ from core's `RequestHandled` / `RequestFailed`, and a socket action fires neither, so
177
+ that context became no trace and everything buffered against it was dropped unread. Not
178
+ only the Flow entry: the queries an action ran, its logs, its N+1 warnings. For a Flow
179
+ app that is the entire interactive half of the application, invisible in the one tool
180
+ built to see it.
181
+
182
+ The bridge now finalises the action's context itself, through the new
183
+ `TraceSink.finalise` in `@zerotal/devtools`. An action appears in the request list
184
+ labelled `FLOW` rather than as a second `GET` of the page it ran on, carrying the same
185
+ queries, logs and timings any request does. Found by wiring DevTools into this repo's
186
+ own `apps/docs` and driving a real action in a browser.
187
+
12
188
  ## [1.7.0] — 2026-08-16
13
189
 
14
190
  ### Fixed
package/README.md CHANGED
@@ -114,7 +114,7 @@ All imported from `@zerotal/flow`.
114
114
  | `@reactive` | A child prop the parent re-pushes whenever it changes, re-rendering the child. |
115
115
  | `@modelable` | A two-way child prop — writes from the child flow back up to the parent. |
116
116
 
117
- Action helpers available inside any exposed method include `this.flash()`, `this.redirect()`, `this.refresh()`, `this.title()`, `this.client()`, `this.download()`, `this.dispatch()`, `this.stream()`, and the `this.errors` proxy (`has`, `any`, `addError`, `resetValidation`). Lifecycle hooks: `setup`, `onBoot`, `onMount`, `onHydrate`, `onUpdating`/`onUpdated`, `onUpdate`, `onRendering`, `onRendered`, `onDehydrate`, `onError`.
117
+ Action helpers available inside any exposed method include `this.flash()`, `this.redirect()`, `this.refresh()`, ``this.$`…` ``, `this.download()`, `this.dispatch()`, `this.stream()`, and the `this.errors` proxy (`has`, `any`, `addError`, `resetValidation`). Lifecycle hooks: `setup`, `onBoot`, `onMount`, `onHydrate`, `onUpdating`/`onUpdated`, `onUpdate`, `onRendering`, `onRendered`, `onDehydrate`, `onError`.
118
118
 
119
119
  ## Built-in components
120
120
 
package/api-surface.md CHANGED
@@ -9,7 +9,9 @@ class Component = {
9
9
  new (): Component
10
10
  static durable?: boolean | { ttl?: string; scope?: 'user' | 'session';}
11
11
  static readonly __isFlowPage: true
12
+ static title?: string | ((component: never) => string)
12
13
  static using: Compose
14
+ $: (strings: TemplateStringsArray, ...values: unknown[]) => void
13
15
  $refresh: () => void
14
16
  $set: (key: string, value: unknown) => void
15
17
  _applyClientUpdate: (key: string, value: unknown, arrayKey?: string) => Promise<void>
@@ -31,10 +33,10 @@ class Component = {
31
33
  _prevChildIds: string[]
32
34
  _redirectStatus: number | null
33
35
  _redirectUrl: string | null
36
+ _resolveTitle: () => string | null
34
37
  _shouldRefresh: boolean
35
38
  _streamSender: ((ref: string, content: string, replace: boolean) => void) | null
36
39
  _taskSignal: AbortSignal | null
37
- _titleValue: string | null
38
40
  _validateField: (field: string) => Promise<void>
39
41
  addError: (field: string, message: string) => void
40
42
  bind: (key: string, optionValue?: string) => Record<string, unknown>
@@ -42,7 +44,7 @@ class Component = {
42
44
  child: <C extends Component>(ChildClass: new () => C, opts?: { key?: string | number; props?: Partial<C>; lazy?: boolean; defer?: boolean; stream?: boolean; slots?: Record<string, string>;}) => Promise<HtmlNode>
43
45
  clearDurable: () => void
44
46
  client: (script: string) => void
45
- currentUrl: (_options?: CurrentUrlOptions) => string
47
+ currentUrl: (options?: CurrentUrlOptions) => string
46
48
  dispatch: <K extends string>(name: K, ...args: K extends EventName ? EventArgs<FlowEvents[K]> : [payload?: Record<string, unknown>]) => void
47
49
  dispatchSelf: <K extends string>(name: K, ...args: K extends EventName ? EventArgs<FlowEvents[K]> : [payload?: Record<string, unknown>]) => void
48
50
  dispatchTo: <K extends string>(component: string, name: K, ...args: K extends EventName ? EventArgs<FlowEvents[K]> : [payload?: Record<string, unknown>]) => void
@@ -51,7 +53,7 @@ class Component = {
51
53
  flash: (message: string, optionsOrLevel?: FlashLevel | FlashOptions) => FlashBuilder
52
54
  hasSlot: (name?: string) => boolean
53
55
  layout: (page: HtmlNode) => HtmlNode | Promise<HtmlNode>
54
- navigateCurrent: (_options?: CurrentUrlOptions) => Promise<void>
56
+ navigateCurrent: (options?: CurrentUrlOptions) => Promise<void>
55
57
  onBoot: (_ctx?: HttpContext) => Promise<void>
56
58
  onDehydrate: () => Promise<void>
57
59
  onError: (error: Error) => Promise<void>
@@ -72,7 +74,6 @@ class Component = {
72
74
  signal: AbortSignal
73
75
  slot: (name?: string) => HtmlNode
74
76
  stream: (ref: string, content: string, opts?: { replace?: boolean;}) => void
75
- title: { (): string; (value: string): void;}
76
77
  validate: (rulesOrForm?: ValidationRules | { __isFlowForm: true; _validateToErrors(): Record<string, string[]>;}) => Promise<void>
77
78
  }
78
79
 
@@ -258,7 +259,7 @@ function Loading = (props: LoadingProps) => HtmlNode
258
259
 
259
260
  function Modal = (props: ModalProps) => HtmlNode
260
261
 
261
- function on = (eventName: LooseEventName) => (fn: unknown, context: ClassMethodDecoratorContext) => void
262
+ function on = (eventName: ListenerName) => (fn: unknown, context: ClassMethodDecoratorContext) => void
262
263
 
263
264
  function Pager = (props: PagerProps) => HtmlNode
264
265
 
@@ -709,6 +710,7 @@ interface LegendProps = {
709
710
  interface LinkProps = {
710
711
  [key: string]: unknown
711
712
  current?: boolean
713
+ down?: boolean
712
714
  exact?: boolean
713
715
  hover?: boolean
714
716
  href: string
@@ -760,7 +762,7 @@ interface PageClassWithMeta = {
760
762
  layout?: new () => Layout
761
763
  models?: Record<string, typeof BaseModel>
762
764
  name: string
763
- title?: string
765
+ title?: string | ((component: never) => string)
764
766
  }
765
767
 
766
768
  interface PagerProps = {
@@ -1038,6 +1040,8 @@ type FlashPosition = 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' |
1038
1040
 
1039
1041
  type HtmlNode = { html: string;}
1040
1042
 
1043
+ type ListenerName = (string & {}) | ((self: Record<string, any>) => string)
1044
+
1041
1045
  type Mixin = (Base: TIn) => TOut
1042
1046
 
1043
1047
  type PresenceChannel = string | ((self: Record<string, any>) => string)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/flow",
3
- "version": "1.7.0",
3
+ "version": "1.7.3",
4
4
  "license": "MIT",
5
5
  "maturity": "stable",
6
6
  "private": false,
@@ -47,14 +47,15 @@
47
47
  "@alpinejs/morph": "^3.14.9",
48
48
  "@alpinejs/persist": "^3.14.9",
49
49
  "@alpinejs/resize": "^3.14.9",
50
- "@zerotal/core": "1.7.0",
51
- "@zerotal/orm": "1.7.0",
52
- "@zerotal/validator": "1.7.0",
50
+ "@zerotal/client": "1.7.3",
51
+ "@zerotal/core": "1.7.3",
52
+ "@zerotal/orm": "1.7.3",
53
+ "@zerotal/validator": "1.7.3",
53
54
  "alpinejs": "^3.14.9",
54
55
  "typescript": "^5.8.0"
55
56
  },
56
57
  "devDependencies": {
57
- "@zerotal/broadcasting": "1.7.0",
58
+ "@zerotal/broadcasting": "1.7.3",
58
59
  "@types/alpinejs": "^3.13.10",
59
60
  "@types/alpinejs__anchor": "^3.13.1",
60
61
  "@types/alpinejs__collapse": "^3.13.4",
package/src/Component.ts CHANGED
@@ -7,7 +7,7 @@ import type {
7
7
  FlashActionStyle,
8
8
  FlashCallback,
9
9
  } from "./types.ts";
10
- import type { CurrentUrlOptions } from "./client/url.ts";
10
+ import { buildUrlWithQuery, type CurrentUrlOptions } from "./client/url.ts";
11
11
  import {
12
12
  getExposedProps,
13
13
  getLockedProps,
@@ -22,6 +22,7 @@ import type { Schema, FieldRuleDefinition } from "@zerotal/validator";
22
22
  import type { ValidateBuilder } from "./decorators.ts";
23
23
  import { ValidationError } from "./validation.ts";
24
24
  import type { ValidationRules } from "./validation.ts";
25
+ import { _applyWireValues, _isRegisteredModel } from "./synths/ModelSynth.ts";
25
26
  import { resolveUploadValue } from "./uploads/TemporaryUploadedFile.ts";
26
27
  import { toScriptJson } from "./utils.ts";
27
28
  import { _compose } from "./mixins.ts";
@@ -174,6 +175,31 @@ function _isProduction(): boolean {
174
175
  }
175
176
 
176
177
  /** Classes already warned about, so a hundred-row list logs once. */
178
+ /**
179
+ * Encode one interpolated value as a JS literal for a client expression.
180
+ *
181
+ * The scripts are evaluated with `Alpine.evaluate(rootEl, script)`, so each value has to
182
+ * arrive as source text. JSON is a subset of JS expression syntax for every shape that
183
+ * survives `JSON.stringify`, which is what makes this exact rather than approximate — and it
184
+ * is what removes the old `@security` note from `client()`, where the caller built the string
185
+ * and owned the escaping.
186
+ *
187
+ * The cases JSON alone gets wrong are handled explicitly: `undefined` and functions stringify
188
+ * to nothing, `NaN` / `Infinity` become `null`, and `BigInt` throws.
189
+ */
190
+ function _encodeClientValue(value: unknown): string {
191
+ if (value === undefined) return "undefined";
192
+ if (typeof value === "bigint") return `${value}n`;
193
+ if (typeof value === "number" && !Number.isFinite(value)) return String(value);
194
+
195
+ const json = JSON.stringify(value);
196
+ if (json === undefined) return "undefined"; // functions, symbols
197
+
198
+ // U+2028/U+2029 are legal inside a JSON string and legal in a JS string literal only since
199
+ // ES2019. Escaping them costs nothing and keeps the output safe in any JS context.
200
+ return json.replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
201
+ }
202
+
177
203
  const _warnedKeyless = new Set<string>();
178
204
 
179
205
  function _warnKeylessChild(className: string): void {
@@ -348,19 +374,6 @@ export interface FlashBuilder {
348
374
 
349
375
  // ── Component ──────────────────────────────────────────────────────────────────────
350
376
 
351
- /**
352
- * Client-only URL helpers compile to `$flow.*` client expressions and never run on the
353
- * server. Reaching this means the call executed server-side — i.e. on a page the compiler
354
- * couldn't statically compile (so it fell back to the runtime renderer), or in a server action.
355
- */
356
- function _clientOnlyUrlHelper(method: string): never {
357
- throw new Error(
358
- `[Flow] this.${method}() is client-only — the compiler rewrites it to \`$flow.${method}\`. ` +
359
- `It ran on the server, which means this page couldn't be statically compiled (e.g. it ` +
360
- `uses a construct the AOT compiler bails on) or you called it from a server action.`,
361
- );
362
- }
363
-
364
377
  /**
365
378
  * Base class for Flow pages — reactive server-side components rendered over a WebSocket.
366
379
  *
@@ -555,9 +568,6 @@ export abstract class Component {
555
568
  /** File downloads queued by download(). Sent as DownloadFrames. @internal */
556
569
  _downloads: Array<{ filename: string; content: string; mime: string }> = [];
557
570
 
558
- /** Dynamic document.title set by title(). Included in the patch frame. @internal */
559
- _titleValue: string | null = null;
560
-
561
571
  /** This component's id — set by the route handler (SSR) or the WS dispatcher. @internal */
562
572
  _flowId = "";
563
573
 
@@ -623,6 +633,26 @@ export abstract class Component {
623
633
  *
624
634
  * @category State & exposure
625
635
  */
636
+ /**
637
+ * The page's document title — a string, or a function of the component.
638
+ *
639
+ * ```ts
640
+ * static title = "Search";
641
+ * static title = (c: SearchPage) => (c.query ? `Search: ${c.query}` : "Search");
642
+ * ```
643
+ *
644
+ * Resolved on the server for every render and every patch, so the function form tracks
645
+ * state without the page doing anything: change `query` in an action and the tab title
646
+ * follows. Only the resolved string is ever sent to the browser.
647
+ *
648
+ * Declared here rather than left to convention. It was read structurally off the
649
+ * constructor, which meant no type, no autocomplete, and no entry in the API surface for
650
+ * something the docs already told people to write.
651
+ *
652
+ * @category Rendering
653
+ */
654
+ static title?: string | ((component: never) => string);
655
+
626
656
  static durable?: boolean | { ttl?: string; scope?: "user" | "session" };
627
657
 
628
658
  /** Set by {@link clearDurable}; the dispatcher deletes the durable entry after the request. @internal */
@@ -1039,7 +1069,9 @@ export abstract class Component {
1039
1069
  * this.client(`$refs.titleInput.focus()`);
1040
1070
  * this.client(`$el.querySelector('.toast').classList.add('visible')`);
1041
1071
  *
1042
- * @security Never interpolate unescaped user input into the expression string.
1072
+ * @security Never interpolate unescaped user input into the expression string. Prefer the
1073
+ * {@link $} tagged template, which encodes interpolated values for you.
1074
+ * @deprecated Use the `$` tagged template: ``this.$`$refs.titleInput.focus()` ``.
1043
1075
  * @category Actions
1044
1076
  */
1045
1077
  client(script: string): void {
@@ -1047,27 +1079,33 @@ export abstract class Component {
1047
1079
  }
1048
1080
 
1049
1081
  /**
1050
- * Gets the current title value.
1051
- * @category Actions
1052
- */
1053
- title(): string;
1054
-
1055
- /**
1056
- * Update `document.title` in the browser after this action completes.
1082
+ * Run a client-side expression from a server action, interpolating values safely.
1057
1083
  *
1058
- * @example
1059
- * this.title(`Search: ${this.query}`);
1084
+ * A tagged template, so each `${…}` is encoded as a JS literal rather than concatenated
1085
+ * into the source by the caller:
1086
+ *
1087
+ * ```ts
1088
+ * this.$`console.log(${this.search})`;
1089
+ * this.$`$refs.title.focus()`;
1090
+ * this.$`$el.dataset.count = ${this.items.length}`;
1091
+ * ```
1092
+ *
1093
+ * That is the whole reason it replaces {@link client}. Building the expression by hand meant
1094
+ * the caller owned the escaping, and a value containing a quote — a name, a search term —
1095
+ * produced either a syntax error or something worse.
1096
+ *
1097
+ * Runs in the component root's Alpine context after the action's DOM patch, exactly as
1098
+ * {@link client} does: `$el`, `$refs` and the `$flow` magics are all in scope, and multiple
1099
+ * calls execute in order after the single round trip.
1060
1100
  *
1061
1101
  * @category Actions
1062
1102
  */
1063
- title(value: string): void;
1064
-
1065
- // Implementation signature (hidden from the public API)
1066
- title(value?: string): void | string {
1067
- if (value === undefined) {
1068
- return this._titleValue ?? (this.constructor as { title?: string }).title ?? "";
1103
+ $(strings: TemplateStringsArray, ...values: unknown[]): void {
1104
+ let script = strings[0] ?? "";
1105
+ for (let i = 0; i < values.length; i++) {
1106
+ script += _encodeClientValue(values[i]) + (strings[i + 1] ?? "");
1069
1107
  }
1070
- this._titleValue = value;
1108
+ this._clientScripts.push(script);
1071
1109
  }
1072
1110
 
1073
1111
  // ── Validation ────────────────────────────────────────────────────────────
@@ -1286,29 +1324,39 @@ export abstract class Component {
1286
1324
  // `$flow` is typed globally (see augment.ts); `$flow.store` is typed by `FlowStore`.
1287
1325
 
1288
1326
  /**
1289
- * Build a URL from the current one with merged query params — client-only. Use it in a
1290
- * JSX expression: `href={this.currentUrl({ query: this.q })}`, `class={this.currentUrl() === "/" ? "on" : ""}`,
1291
- * or `{this.currentUrl(...)}`. The compiler rewrites `this.currentUrl` → `$flow.currentUrl`
1292
- * so it runs on the client. It never runs on the server — hitting this throw means the call
1293
- * reached server code (e.g. a page the compiler couldn't statically compile, or a server action).
1327
+ * Build a URL from the current one with merged query params. Listed params are added or
1328
+ * updated, unlisted ones are preserved, and a `null` / `undefined` / `""` value removes one.
1329
+ *
1330
+ * Works on both render paths, which is the point. Written in JSX —
1331
+ * `href={this.currentUrl({ query: { page: 2 } })}` — the compiler rewrites it to
1332
+ * `$flow.currentUrl` and it runs in the browser against `location.href`. Reached from server
1333
+ * code it does the same work against the current request's URL, through the same pure
1334
+ * {@link buildUrlWithQuery} helper, so the two paths cannot drift.
1335
+ *
1336
+ * It used to throw on the server, on the reasoning that the compiler always rewrote it. The
1337
+ * compiler bails for reasons that have nothing to do with this call — one `__()` anywhere in
1338
+ * the page is enough — and the page then renders through the runtime renderer, where this
1339
+ * executes server-side. A translated page with a paginated link crashed.
1294
1340
  *
1295
- * @throws {Error} when executed on the server (it is meant to be compiled to `$flow.currentUrl`).
1296
1341
  * @category Navigation & redirects
1297
1342
  */
1298
- currentUrl(_options?: CurrentUrlOptions): string {
1299
- return _clientOnlyUrlHelper("currentUrl");
1343
+ currentUrl(options: CurrentUrlOptions = {}): string {
1344
+ return buildUrlWithQuery(request().url.href, options);
1300
1345
  }
1301
1346
 
1302
1347
  /**
1303
- * Build the URL as {@link currentUrl} does, then SPA-navigate to it — client-only. Use it in a
1304
- * client handler: `onClick={() => this.navigateCurrent({ query: { page: 2 } })}`. The compiler
1305
- * rewrites it to `$flow.navigateCurrent`. To navigate from server code, return a `redirect()`.
1348
+ * Build the URL as {@link currentUrl} does, then go to it.
1349
+ *
1350
+ * In a JSX handler — `onClick={() => this.navigateCurrent({ query: { page: 2 } })}` — the
1351
+ * compiler rewrites it to `$flow.navigateCurrent` and the browser SPA-navigates. Reached from
1352
+ * server code it queues a redirect to the same URL: a different mechanism for the same
1353
+ * intent, and the one the server has. See {@link currentUrl} for why both paths must work.
1306
1354
  *
1307
- * @throws {Error} when executed on the server (it is meant to be compiled to `$flow.navigateCurrent`).
1308
1355
  * @category Navigation & redirects
1309
1356
  */
1310
- navigateCurrent(_options?: CurrentUrlOptions): Promise<void> {
1311
- return _clientOnlyUrlHelper("navigateCurrent");
1357
+ navigateCurrent(options: CurrentUrlOptions = {}): Promise<void> {
1358
+ this.redirect(buildUrlWithQuery(request().url.href, options));
1359
+ return Promise.resolve();
1312
1360
  }
1313
1361
 
1314
1362
  // ── File downloads ────────────────────────────────────────────────────────
@@ -1685,6 +1733,21 @@ export abstract class Component {
1685
1733
  (cur as { fill(v: Record<string, unknown>): void }).fill(value as Record<string, unknown>);
1686
1734
  return;
1687
1735
  }
1736
+ // Model: same reasoning as the form above — fill the hydrated instance rather than
1737
+ // replacing it with the plain object the client sent, so its class, methods and dirty
1738
+ // tracking survive a `value={this.user.name}` edit. `_applyWireValues` keeps only
1739
+ // `fillable` minus `hidden`, so a field the model never offered is ignored rather than
1740
+ // written or thrown over.
1741
+ if (
1742
+ cur &&
1743
+ typeof cur === "object" &&
1744
+ _isRegisteredModel(cur) &&
1745
+ value &&
1746
+ typeof value === "object"
1747
+ ) {
1748
+ _applyWireValues(cur, value);
1749
+ return;
1750
+ }
1688
1751
  // Signed upload references become TemporaryUploadedFile instances (signature verified);
1689
1752
  // all other values pass through unchanged.
1690
1753
  (this as Record<string, unknown>)[key] = resolveUploadValue(value);
@@ -1737,6 +1800,22 @@ export abstract class Component {
1737
1800
 
1738
1801
  // ── Effect draining (called by WS handler after action) ──────────────────
1739
1802
 
1803
+ /**
1804
+ * The title to send with this frame — `static title` resolved against this component.
1805
+ *
1806
+ * Resolved per frame rather than once, which is what lets the function form track state:
1807
+ * change what it reads and the next patch carries the new title.
1808
+ *
1809
+ * @internal
1810
+ */
1811
+ _resolveTitle(): string | null {
1812
+ const declared = (this.constructor as { title?: unknown }).title;
1813
+ if (typeof declared === "function") {
1814
+ return String((declared as (c: unknown) => unknown)(this));
1815
+ }
1816
+ return typeof declared === "string" ? declared : null;
1817
+ }
1818
+
1740
1819
  /** @internal — read by the WS handler after action execution. */
1741
1820
  _drainEffects(): FlowEffects {
1742
1821
  const effects: FlowEffects = {
@@ -1747,12 +1826,11 @@ export abstract class Component {
1747
1826
  errors: this._errors,
1748
1827
  events: this._events.splice(0),
1749
1828
  downloads: this._downloads.splice(0),
1750
- title: this._titleValue,
1829
+ title: this._resolveTitle(),
1751
1830
  };
1752
1831
  this._redirectUrl = null;
1753
1832
  this._redirectStatus = null;
1754
1833
  this._shouldRefresh = false;
1755
- this._titleValue = null;
1756
1834
  // _errors intentionally NOT reset here: errors persist across actions so the
1757
1835
  // client continues showing field messages after e.g. a failed save that then
1758
1836
  // dispatches a flash. Call resetValidation() or a passing validate() to clear.
@@ -76,8 +76,13 @@ export class FlowBrowser {
76
76
  // than on a sleep is what makes these tests deterministic: a slow boot
77
77
  // waits longer, and a bridge that never connects fails with a clear reason
78
78
  // instead of a mystery assertion further down.
79
+ //
80
+ // Guarded on `document.body` existing: the poll can land between a
81
+ // navigation committing and the body being parsed, and `null.getAttribute`
82
+ // throws out of `evaluate` rather than returning false — so the wait died
83
+ // with a TypeError instead of polling once more.
79
84
  await page.waitUntil(
80
- `document.body.getAttribute("data-flow-connection") === "online"`,
85
+ `!!document.body && document.body.getAttribute("data-flow-connection") === "online"`,
81
86
  "Flow to connect its WebSocket",
82
87
  timeout,
83
88
  );
@@ -125,7 +130,7 @@ export class FlowBrowser {
125
130
 
126
131
  if (options.waitForConnection !== false) {
127
132
  await this.waitUntil(
128
- `document.body.getAttribute("data-flow-connection") === "online"`,
133
+ `!!document.body && document.body.getAttribute("data-flow-connection") === "online"`,
129
134
  "Flow to connect its WebSocket",
130
135
  timeout,
131
136
  );