@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 +176 -0
- package/README.md +1 -1
- package/api-surface.md +10 -6
- package/package.json +6 -5
- package/src/Component.ts +128 -50
- package/src/browser/FlowBrowser.ts +7 -2
- package/src/client/bridge.ts +353 -83
- package/src/client/framePanes.ts +229 -0
- package/src/client/headless.ts +7 -1
- package/src/client/timeline.ts +12 -0
- package/src/components.ts +13 -2
- package/src/decorators.ts +104 -14
- package/src/dehydrate.ts +51 -2
- package/src/durable.ts +5 -2
- package/src/events.ts +3 -3
- package/src/index.ts +1 -0
- package/src/jsx-runtime.ts +13 -0
- package/src/observability.ts +28 -1
- package/src/registry.ts +6 -2
- package/src/router.ts +7 -1
- package/src/synths/CollectionSynth.ts +17 -15
- package/src/synths/ModelSynth.ts +335 -35
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()`,
|
|
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: (
|
|
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: (
|
|
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:
|
|
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.
|
|
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/
|
|
51
|
-
"@zerotal/
|
|
52
|
-
"@zerotal/
|
|
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.
|
|
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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
1059
|
-
*
|
|
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
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
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.
|
|
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
|
|
1290
|
-
*
|
|
1291
|
-
*
|
|
1292
|
-
*
|
|
1293
|
-
*
|
|
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(
|
|
1299
|
-
return
|
|
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
|
|
1304
|
-
*
|
|
1305
|
-
*
|
|
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(
|
|
1311
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
133
|
+
`!!document.body && document.body.getAttribute("data-flow-connection") === "online"`,
|
|
129
134
|
"Flow to connect its WebSocket",
|
|
130
135
|
timeout,
|
|
131
136
|
);
|