@zerotal/flow 1.0.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 +61 -0
- package/LICENSE +21 -0
- package/README.md +148 -0
- package/package.json +84 -0
- package/src/Component.ts +1498 -0
- package/src/Form.ts +189 -0
- package/src/Layout.ts +47 -0
- package/src/augment.ts +94 -0
- package/src/client/FlowClientError.ts +12 -0
- package/src/client/FlowComponent.ts +96 -0
- package/src/client/alpinejs-morph.d.ts +5 -0
- package/src/client/bridge.ts +3463 -0
- package/src/client/confirm.ts +338 -0
- package/src/client/cspAdapter.ts +79 -0
- package/src/client/cspEvaluator.ts +494 -0
- package/src/client/errorOverlay.ts +124 -0
- package/src/client/headless.ts +948 -0
- package/src/client/index.csp.ts +47 -0
- package/src/client/index.ts +56 -0
- package/src/client/optimistic.ts +52 -0
- package/src/client/timeline.ts +160 -0
- package/src/client/url.ts +51 -0
- package/src/commands/MakeFlowCommand.ts +290 -0
- package/src/compiler/cache.ts +101 -0
- package/src/compiler/directives.ts +107 -0
- package/src/compiler/helpers.ts +26 -0
- package/src/compiler/index.ts +172 -0
- package/src/compiler/transform.ts +2070 -0
- package/src/components.ts +1465 -0
- package/src/config.ts +52 -0
- package/src/decorators.ts +978 -0
- package/src/dehydrate.ts +333 -0
- package/src/durable.ts +243 -0
- package/src/events.ts +118 -0
- package/src/frameworkEvents.ts +40 -0
- package/src/headless.ts +1147 -0
- package/src/index.ts +267 -0
- package/src/jsx-dev-runtime.ts +14 -0
- package/src/jsx-runtime.ts +1272 -0
- package/src/mixins.ts +199 -0
- package/src/observability.ts +167 -0
- package/src/pagination.ts +300 -0
- package/src/presence.ts +81 -0
- package/src/provider/FlowProvider.ts +1401 -0
- package/src/registry.ts +118 -0
- package/src/renderContext.ts +55 -0
- package/src/router.ts +401 -0
- package/src/shared.ts +226 -0
- package/src/store.ts +116 -0
- package/src/synths/CarbonIntervalSynth.ts +22 -0
- package/src/synths/CarbonSynth.ts +20 -0
- package/src/synths/CollectionSynth.ts +62 -0
- package/src/synths/DateSynth.ts +24 -0
- package/src/synths/ModelSynth.ts +78 -0
- package/src/synths/TemporaryUploadedFileSynth.ts +19 -0
- package/src/synths/index.ts +148 -0
- package/src/testing.ts +361 -0
- package/src/types.ts +315 -0
- package/src/uploads/FileUploads.ts +94 -0
- package/src/uploads/TemporaryUploadedFile.ts +274 -0
- package/src/uploads/sign.ts +18 -0
- package/src/urlSafety.ts +0 -0
- package/src/utils.ts +216 -0
- package/src/validation.ts +55 -0
package/src/Component.ts
ADDED
|
@@ -0,0 +1,1498 @@
|
|
|
1
|
+
import type { HtmlNode } from "./jsx-runtime.ts";
|
|
2
|
+
import type {
|
|
3
|
+
FlashLevel,
|
|
4
|
+
FlashMessage,
|
|
5
|
+
FlashPosition,
|
|
6
|
+
FlashAction,
|
|
7
|
+
FlashActionStyle,
|
|
8
|
+
FlashCallback,
|
|
9
|
+
} from "./types.ts";
|
|
10
|
+
import type { CurrentUrlOptions } from "./client/url.ts";
|
|
11
|
+
import {
|
|
12
|
+
getExposedProps,
|
|
13
|
+
getLockedProps,
|
|
14
|
+
getValidateRules,
|
|
15
|
+
getReactiveProps,
|
|
16
|
+
getModelableProps,
|
|
17
|
+
} from "./decorators.ts";
|
|
18
|
+
import { _renderFlowPage } from "./jsx-runtime.ts";
|
|
19
|
+
import { RuleBuilder, runValidationAsync } from "@zerotal/validator";
|
|
20
|
+
import type { Schema, FieldRuleDefinition } from "@zerotal/validator";
|
|
21
|
+
import type { ValidateBuilder } from "./decorators.ts";
|
|
22
|
+
import { ValidationError } from "./validation.ts";
|
|
23
|
+
import type { ValidationRules } from "./validation.ts";
|
|
24
|
+
import { resolveUploadValue } from "./uploads/TemporaryUploadedFile.ts";
|
|
25
|
+
import { toScriptJson } from "./utils.ts";
|
|
26
|
+
import { _validateEventPayload } from "./events.ts";
|
|
27
|
+
import type { FlowEvents, EventName, EventArgs } from "./events.ts";
|
|
28
|
+
import { route, request, safeRedirectPath, HttpContext } from "@zerotal/core";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Convert the validator's one-message-per-field result (`Record<string, string>`) into
|
|
32
|
+
* Flow's error bag (`Record<string, string[]>`), which the `$errors` proxy + client expect.
|
|
33
|
+
*/
|
|
34
|
+
function _toErrorBag(errors: Record<string, string>): Record<string, string[]> {
|
|
35
|
+
const out: Record<string, string[]> = {};
|
|
36
|
+
for (const [field, msg] of Object.entries(errors)) out[field] = [msg];
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A signal that never aborts — returned by `Component.signal` outside a running `@task`. */
|
|
41
|
+
const _NEVER_ABORT: AbortSignal = new AbortController().signal;
|
|
42
|
+
|
|
43
|
+
// ── ErrorField sentinel ──────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Sentinel returned by the {@link Component.errors} proxy when a field is accessed as a property
|
|
47
|
+
* (`this.errors.email`). The JSX compiler detects `__isErrorField` and emits `flow:error` +
|
|
48
|
+
* `flow:show`, so `error={this.errors.email}` wires up display of that field's first message.
|
|
49
|
+
*/
|
|
50
|
+
export class ErrorField {
|
|
51
|
+
readonly __isErrorField = true as const;
|
|
52
|
+
readonly __field: string;
|
|
53
|
+
readonly __value: string;
|
|
54
|
+
constructor(field: string, messages: string[]) {
|
|
55
|
+
this.__field = field;
|
|
56
|
+
this.__value = messages[0] ?? "";
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ── ErrorsProxy ───────────────────────────────────────────────────────────────
|
|
61
|
+
// Returned by Component.errors. Provides:
|
|
62
|
+
// this.errors.has("field") → boolean
|
|
63
|
+
// this.errors.field → ErrorField (for error={this.errors.field})
|
|
64
|
+
|
|
65
|
+
export interface ErrorsProxy {
|
|
66
|
+
/** True when the given field has at least one error. */
|
|
67
|
+
has(field: string): boolean;
|
|
68
|
+
/** True when any field has an error. */
|
|
69
|
+
any(): boolean;
|
|
70
|
+
/**
|
|
71
|
+
* Add one or more validation errors manually.
|
|
72
|
+
*
|
|
73
|
+
* @example
|
|
74
|
+
* this.errors.add("email", "Invalid email or password.");
|
|
75
|
+
* this.errors.add({ email: "Invalid email or password." });
|
|
76
|
+
* this.errors.add({ password: ["Too short", "Needs a number"] });
|
|
77
|
+
*/
|
|
78
|
+
add(field: string, message: string): void;
|
|
79
|
+
add(errors: Record<string, string | string[]>): void;
|
|
80
|
+
/** Clear errors — all fields, or just the named one. */
|
|
81
|
+
clear(field?: string): void;
|
|
82
|
+
// Field access (`this.errors.email`) returns an ErrorField for `error={…}`.
|
|
83
|
+
[field: string]: ErrorField | ((...args: never[]) => unknown);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface ErrorsProxyHooks {
|
|
87
|
+
read(): Record<string, string[]>;
|
|
88
|
+
add(field: string, message: string): void;
|
|
89
|
+
clear(field?: string): void;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function _makeErrorsProxy(hooks: ErrorsProxyHooks): ErrorsProxy {
|
|
93
|
+
return new Proxy({} as ErrorsProxy, {
|
|
94
|
+
get(_t, prop: string | symbol) {
|
|
95
|
+
if (typeof prop !== "string") return undefined;
|
|
96
|
+
if (prop === "has") {
|
|
97
|
+
return (field: string) => {
|
|
98
|
+
const msgs = hooks.read()[field];
|
|
99
|
+
return Array.isArray(msgs) && msgs.length > 0;
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
if (prop === "any") {
|
|
103
|
+
return () => {
|
|
104
|
+
const errors = hooks.read();
|
|
105
|
+
return Object.keys(errors).some(
|
|
106
|
+
(key) => Array.isArray(errors[key]) && errors[key].length > 0,
|
|
107
|
+
);
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
if (prop === "add") {
|
|
111
|
+
return (fieldOrMap: string | Record<string, string | string[]>, message?: string) => {
|
|
112
|
+
if (typeof fieldOrMap === "string") {
|
|
113
|
+
hooks.add(fieldOrMap, message ?? "");
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
for (const [field, msg] of Object.entries(fieldOrMap)) {
|
|
117
|
+
for (const m of Array.isArray(msg) ? msg : [msg]) hooks.add(field, m);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
if (prop === "clear") {
|
|
122
|
+
return (field?: string) => hooks.clear(field);
|
|
123
|
+
}
|
|
124
|
+
const msgs = hooks.read()[prop] ?? [];
|
|
125
|
+
return new ErrorField(prop, msgs);
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Escape a JSON string for embedding inside a single-quoted HTML attribute.
|
|
131
|
+
* The browser decodes the entities back to valid JSON in `el.dataset`. */
|
|
132
|
+
function _attrJson(s: string): string {
|
|
133
|
+
return s.replace(/&/g, "&").replace(/'/g, "'").replace(/</g, "<");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ── Internal effect bags ──────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The bag of side effects an action queued (flashes, a redirect, client scripts, events,
|
|
140
|
+
* downloads, a title, and the current error bag). Drained by {@link Component._drainEffects}
|
|
141
|
+
* and read by the WebSocket handler to build the response frames sent to the client.
|
|
142
|
+
*/
|
|
143
|
+
export interface FlowEffects {
|
|
144
|
+
flashes: FlashMessage[];
|
|
145
|
+
redirectUrl: string | null;
|
|
146
|
+
shouldRefresh: boolean;
|
|
147
|
+
scripts: string[];
|
|
148
|
+
errors: Record<string, string[]>;
|
|
149
|
+
events: Array<{ name: string; data: Record<string, unknown>; to?: string; self?: boolean }>;
|
|
150
|
+
downloads: Array<{ filename: string; content: string; mime: string }>;
|
|
151
|
+
title: string | null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── Redirect builder ───────────────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Builder returned by {@link Component.redirect} (and `redirectRoute`/`redirectIntended`) so a
|
|
158
|
+
* flash can be chained onto the redirect, mirroring the HTTP ResponseBuilder API.
|
|
159
|
+
*
|
|
160
|
+
* @example
|
|
161
|
+
* ```ts
|
|
162
|
+
* return this.redirect("/", 303).withSuccess("Welcome back.");
|
|
163
|
+
* ```
|
|
164
|
+
*/
|
|
165
|
+
export interface RedirectFlash {
|
|
166
|
+
withSuccess(message: string): this;
|
|
167
|
+
withError(message: string): this;
|
|
168
|
+
withInfo(message: string): this;
|
|
169
|
+
withWarning(message: string): this;
|
|
170
|
+
/** Flash with an explicit level. */
|
|
171
|
+
with(message: string, level: FlashLevel): this;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Options accepted by `this.flash(message, options)` — the object form of the
|
|
176
|
+
* fluent builder, for when config is computed dynamically. `type` is an alias
|
|
177
|
+
* for `level` (Tailwind/Notyf parlance).
|
|
178
|
+
*/
|
|
179
|
+
export interface FlashOptions {
|
|
180
|
+
level?: FlashLevel;
|
|
181
|
+
/** Alias for `level`. */
|
|
182
|
+
type?: FlashLevel;
|
|
183
|
+
title?: string;
|
|
184
|
+
position?: FlashPosition;
|
|
185
|
+
/** Auto-dismiss after N ms. `0` keeps the toast until dismissed. */
|
|
186
|
+
duration?: number;
|
|
187
|
+
dismissible?: boolean;
|
|
188
|
+
/** Custom icon (emoji/text), or `false` to hide the status icon. */
|
|
189
|
+
icon?: string | false;
|
|
190
|
+
progressBar?: boolean;
|
|
191
|
+
/** Action buttons that each invoke a `@expose` method on click. */
|
|
192
|
+
actions?: FlashAction[];
|
|
193
|
+
/** A `@expose` method invoked when the toast is dismissed. */
|
|
194
|
+
onClose?: FlashCallback;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Fluent toast builder returned by `this.flash(...)`. Each setter mutates the
|
|
199
|
+
* queued flash in place and returns the builder, so calls chain:
|
|
200
|
+
*
|
|
201
|
+
* @example
|
|
202
|
+
* this.flash("Post deleted")
|
|
203
|
+
* .title("Removed")
|
|
204
|
+
* .warning()
|
|
205
|
+
* .position("top-center")
|
|
206
|
+
* .duration(8000)
|
|
207
|
+
* .progressBar();
|
|
208
|
+
*/
|
|
209
|
+
export interface FlashBuilder {
|
|
210
|
+
/** Set the status level explicitly. */
|
|
211
|
+
level(level: FlashLevel): this;
|
|
212
|
+
success(): this;
|
|
213
|
+
error(): this;
|
|
214
|
+
warning(): this;
|
|
215
|
+
info(): this;
|
|
216
|
+
/** Bold header above the message. */
|
|
217
|
+
title(title: string): this;
|
|
218
|
+
/** Anchor this toast at a screen corner (overrides the `<Flash>` default). */
|
|
219
|
+
position(position: FlashPosition): this;
|
|
220
|
+
/** Auto-dismiss after `ms`. Pass `0` to keep it until the user dismisses it. */
|
|
221
|
+
duration(ms: number): this;
|
|
222
|
+
/** Keep the toast until dismissed (equivalent to `duration(0)`). */
|
|
223
|
+
noAutoDismiss(): this;
|
|
224
|
+
/** Show/hide the close (×) button. Defaults to `true`. */
|
|
225
|
+
dismissible(value?: boolean): this;
|
|
226
|
+
/** Override the status icon with emoji/text, or pass `false` to hide it. */
|
|
227
|
+
icon(icon: string | false): this;
|
|
228
|
+
/** Show a countdown progress bar. Defaults to `true`. */
|
|
229
|
+
progressBar(value?: boolean): this;
|
|
230
|
+
/**
|
|
231
|
+
* Add an action button that invokes a `@expose` method on click (then dismisses).
|
|
232
|
+
* Call it more than once to render multiple buttons. `method` is the method name;
|
|
233
|
+
* `args` are forwarded to it; `style` is a color shorthand or a {@link FlashActionStyle}.
|
|
234
|
+
*
|
|
235
|
+
* @example
|
|
236
|
+
* this.flash("Post deleted")
|
|
237
|
+
* .action("Undo", "restorePost", [postId], "info")
|
|
238
|
+
* .action("Delete forever", "purge", [postId], { color: "error", variant: "solid", uppercase: true });
|
|
239
|
+
*/
|
|
240
|
+
action(label: string, method: string, args?: unknown[], style?: string | FlashActionStyle): this;
|
|
241
|
+
/**
|
|
242
|
+
* Invoke a `@expose` method when the toast is dismissed (timeout, ×, or action).
|
|
243
|
+
*
|
|
244
|
+
* @example this.flash("Heads up").noAutoDismiss().onClose("acknowledge");
|
|
245
|
+
*/
|
|
246
|
+
onClose(method: string, args?: unknown[]): this;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ── Component ──────────────────────────────────────────────────────────────────────
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Client-only URL helpers compile to `$flow.*` client expressions and never run on the
|
|
253
|
+
* server. Reaching this means the call executed server-side — i.e. on a page the compiler
|
|
254
|
+
* couldn't statically compile (so it fell back to the runtime renderer), or in a server action.
|
|
255
|
+
*/
|
|
256
|
+
function _clientOnlyUrlHelper(method: string): never {
|
|
257
|
+
throw new Error(
|
|
258
|
+
`[Flow] this.${method}() is client-only — the compiler rewrites it to \`$flow.${method}\`. ` +
|
|
259
|
+
`It ran on the server, which means this page couldn't be statically compiled (e.g. it ` +
|
|
260
|
+
`uses a construct the AOT compiler bails on) or you called it from a server action.`,
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Base class for Flow pages — reactive server-side components rendered over a WebSocket.
|
|
266
|
+
*
|
|
267
|
+
* @remarks
|
|
268
|
+
* A Flow component's state lives on the **server**. You subclass `Component`, hold state in
|
|
269
|
+
* fields, and implement {@link render} to return an {@link HtmlNode} (authored in `.tsx` with
|
|
270
|
+
* `jsxImportSource: "@zerotal/flow"`). The root element must carry `data-flow-root`.
|
|
271
|
+
*
|
|
272
|
+
* Only fields and methods the component opts into are reachable from the browser:
|
|
273
|
+
* - `@expose` marks a field as reactive (streamed to the client, writable via `flow:model` /
|
|
274
|
+
* `$flow.$set`) or a method as a callable **action** (invoked over the socket, e.g. `flow:click`).
|
|
275
|
+
* - `@locked` sends a field to the client read-only — it round-trips in the snapshot but the
|
|
276
|
+
* client cannot mutate it (enforced in {@link $set}).
|
|
277
|
+
*
|
|
278
|
+
* Between requests, the component is serialised into an **HMAC-signed snapshot** and rehydrated
|
|
279
|
+
* on the next round-trip, so tampering with client-held state is rejected. When an action runs,
|
|
280
|
+
* the server re-renders and streams a DOM diff that the browser applies via Alpine's morph —
|
|
281
|
+
* only the changed HTML crosses the wire.
|
|
282
|
+
*
|
|
283
|
+
* Lifecycle hooks fire in order around each request: {@link onBoot} → ({@link onMount} on the
|
|
284
|
+
* initial GET / {@link onHydrate} on round-trips) → property updates → the action →
|
|
285
|
+
* {@link onUpdate} → {@link onRendering} → {@link render} → {@link onRendered} →
|
|
286
|
+
* {@link onDehydrate}. Actions may emit side effects — flashes, redirects, events, downloads,
|
|
287
|
+
* client scripts — which are drained after the action and folded into the response frame.
|
|
288
|
+
*
|
|
289
|
+
* @example
|
|
290
|
+
* A counter with an exposed field and an action, bound reactively:
|
|
291
|
+
* ```tsx
|
|
292
|
+
* import { Component, expose } from "@zerotal/flow";
|
|
293
|
+
*
|
|
294
|
+
* export class Counter extends Component {
|
|
295
|
+
* @expose count = 0;
|
|
296
|
+
*
|
|
297
|
+
* @expose increment() {
|
|
298
|
+
* this.count++;
|
|
299
|
+
* }
|
|
300
|
+
*
|
|
301
|
+
* override async render() {
|
|
302
|
+
* return (
|
|
303
|
+
* <div data-flow-root>
|
|
304
|
+
* <p>{this.count}</p>
|
|
305
|
+
* <button flow:click="increment">+</button>
|
|
306
|
+
* </div>
|
|
307
|
+
* );
|
|
308
|
+
* }
|
|
309
|
+
* }
|
|
310
|
+
* ```
|
|
311
|
+
*
|
|
312
|
+
* @example
|
|
313
|
+
* A form-style component with validation, a flash, and a redirect from an action:
|
|
314
|
+
* ```tsx
|
|
315
|
+
* import { Component, expose, locked } from "@zerotal/flow";
|
|
316
|
+
*
|
|
317
|
+
* export class ProfilePage extends Component {
|
|
318
|
+
* @locked userId = 0;
|
|
319
|
+
* @expose name = "";
|
|
320
|
+
*
|
|
321
|
+
* @expose async save() {
|
|
322
|
+
* await this.validate({ name: (rule) => rule.required().min(2) });
|
|
323
|
+
* await User.update(this.userId, { name: this.name });
|
|
324
|
+
* return this.redirect("/dashboard").withSuccess("Profile saved.");
|
|
325
|
+
* }
|
|
326
|
+
*
|
|
327
|
+
* override async render() {
|
|
328
|
+
* return (
|
|
329
|
+
* <div data-flow-root>
|
|
330
|
+
* <input {...this.bind("name")} />
|
|
331
|
+
* {this.errors.has("name") && <span>{this.errors.name}</span>}
|
|
332
|
+
* <button flow:click="save">Save</button>
|
|
333
|
+
* </div>
|
|
334
|
+
* );
|
|
335
|
+
* }
|
|
336
|
+
* }
|
|
337
|
+
* ```
|
|
338
|
+
*/
|
|
339
|
+
export abstract class Component {
|
|
340
|
+
/**
|
|
341
|
+
* Brand identifying Flow components — used for file-route detection.
|
|
342
|
+
* @internal
|
|
343
|
+
*/
|
|
344
|
+
static readonly __isFlowPage = true as const;
|
|
345
|
+
|
|
346
|
+
// ── @internal: framework bookkeeping, not part of the developer API ──────────
|
|
347
|
+
|
|
348
|
+
/** Set to true by hydrate() so the framework skips calling onMount(). @internal */
|
|
349
|
+
_skipMount = false;
|
|
350
|
+
|
|
351
|
+
/** Populated by flash() — read by the WS handler to emit flash frames. @internal */
|
|
352
|
+
_flashes: FlashMessage[] = [];
|
|
353
|
+
|
|
354
|
+
/** Set by redirect() — read by the WS handler to emit a redirect frame. @internal */
|
|
355
|
+
_redirectUrl: string | null = null;
|
|
356
|
+
|
|
357
|
+
/** Optional HTTP-style status passed to redirect() (advisory; client navigates regardless). @internal */
|
|
358
|
+
_redirectStatus: number | null = null;
|
|
359
|
+
|
|
360
|
+
/** Set by refresh() — re-runs onMount() before the action executes. @internal */
|
|
361
|
+
_shouldRefresh = false;
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Raw JS expressions queued by this.client() during an action.
|
|
365
|
+
* Drained by _drainEffects() and forwarded to the client in the patch frame.
|
|
366
|
+
* @internal
|
|
367
|
+
*/
|
|
368
|
+
_clientScripts: string[] = [];
|
|
369
|
+
|
|
370
|
+
/** Validation errors set by validate() / addError(). Included in the patch frame. @internal */
|
|
371
|
+
_errors: Record<string, string[]> = {};
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Typed access to this component's validation error bag.
|
|
375
|
+
*
|
|
376
|
+
* - `this.errors.has("field")` → `boolean`
|
|
377
|
+
* - `this.errors.field` → an {@link ErrorField} for `error={this.errors.field}`
|
|
378
|
+
* - `this.errors.add("field", "message")` → add an error manually
|
|
379
|
+
* - `this.errors.add({ field: "message" })` → add several at once
|
|
380
|
+
* - `this.errors.clear(field?)` → clear one field, or all
|
|
381
|
+
*
|
|
382
|
+
* @category Validation
|
|
383
|
+
*/
|
|
384
|
+
get errors(): ErrorsProxy {
|
|
385
|
+
return _makeErrorsProxy({
|
|
386
|
+
read: () => this._errors,
|
|
387
|
+
add: (field, message) => this.addError(field, message),
|
|
388
|
+
clear: (field) => this.resetValidation(field),
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** Cross-component events queued by dispatch(). Sent as EventFrames.
|
|
393
|
+
* `to` restricts delivery to components of that class name; `self` to this one. @internal */
|
|
394
|
+
_events: Array<{ name: string; data: Record<string, unknown>; to?: string; self?: boolean }> = [];
|
|
395
|
+
|
|
396
|
+
/** File downloads queued by download(). Sent as DownloadFrames. @internal */
|
|
397
|
+
_downloads: Array<{ filename: string; content: string; mime: string }> = [];
|
|
398
|
+
|
|
399
|
+
/** Dynamic document.title set by title(). Included in the patch frame. @internal */
|
|
400
|
+
_titleValue: string | null = null;
|
|
401
|
+
|
|
402
|
+
/** This component's id — set by the route handler (SSR) or the WS dispatcher. @internal */
|
|
403
|
+
_flowId = "";
|
|
404
|
+
|
|
405
|
+
/** The page route's path — inherited by nested children for middleware lookup. @internal */
|
|
406
|
+
_flowPath = "";
|
|
407
|
+
|
|
408
|
+
/** True when this instance was rebuilt from a snapshot (WS update render). @internal */
|
|
409
|
+
_isHydrated = false;
|
|
410
|
+
|
|
411
|
+
/** Child component ids collected during the current render (→ memo.children). @internal */
|
|
412
|
+
_childIds: string[] = [];
|
|
413
|
+
|
|
414
|
+
/** Child ids from the previous render (frame.snapshot.memo.children). @internal */
|
|
415
|
+
_prevChildIds: string[] = [];
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Named slot HTML this component received from its parent's template (`name → html`,
|
|
419
|
+
* default slot keyed `"default"`). Populated by the parent's `child()` call and restored
|
|
420
|
+
* from the snapshot on every round-trip. Read it in `render()` via `this.slot(name)`.
|
|
421
|
+
* @internal
|
|
422
|
+
*/
|
|
423
|
+
_flowSlots: Record<string, string> = {};
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Live stream sender — attached by the WS dispatcher for the duration of an
|
|
427
|
+
* action so this.stream() can push frames mid-execution. No-op during SSR.
|
|
428
|
+
* @internal
|
|
429
|
+
*/
|
|
430
|
+
_streamSender: ((ref: string, content: string, replace: boolean) => void) | null = null;
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* The cancellation signal for the currently-running `@task` action — attached by the WS
|
|
434
|
+
* dispatcher for the task's duration, tripped when the client calls `this.cancel()`.
|
|
435
|
+
* Null outside a task. Read it through {@link signal} / {@link cancelled}.
|
|
436
|
+
* @internal
|
|
437
|
+
*/
|
|
438
|
+
_taskSignal: AbortSignal | null = null;
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* The AbortSignal for the running `@task`. Pass it to `fetch`/an SDK, or check
|
|
442
|
+
* `this.signal.aborted` in a loop, for cooperative cancellation. Outside a task it is an
|
|
443
|
+
* already-inert signal that never aborts, so `@task` code reads the same regardless.
|
|
444
|
+
*
|
|
445
|
+
* @category Actions
|
|
446
|
+
*/
|
|
447
|
+
get signal(): AbortSignal {
|
|
448
|
+
return this._taskSignal ?? _NEVER_ABORT;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* True when the running `@task` has been cancelled from the client.
|
|
453
|
+
* @category Actions
|
|
454
|
+
*/
|
|
455
|
+
get cancelled(): boolean {
|
|
456
|
+
return this._taskSignal?.aborted ?? false;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Opt into durable/resumable snapshots: `static durable = true` (or `{ ttl, scope }`).
|
|
461
|
+
* The signed snapshot is persisted server-side after every request, keyed by user (or
|
|
462
|
+
* session) + route, and restored on a fresh GET so the user resumes exactly. See
|
|
463
|
+
* {@link clearDurable} to drop the stored state on flow completion.
|
|
464
|
+
*
|
|
465
|
+
* @category State & exposure
|
|
466
|
+
*/
|
|
467
|
+
static durable?: boolean | { ttl?: string; scope?: "user" | "session" };
|
|
468
|
+
|
|
469
|
+
/** Set by {@link clearDurable}; the dispatcher deletes the durable entry after the request. @internal */
|
|
470
|
+
_clearDurable = false;
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Forget this component's durable snapshot at the end of the current request — call it when a
|
|
474
|
+
* durable flow completes (a wizard finished, a form submitted) so the next visit starts fresh
|
|
475
|
+
* instead of resuming the finished state. No-op unless the component opted into `static durable`.
|
|
476
|
+
*
|
|
477
|
+
* @category State & exposure
|
|
478
|
+
*/
|
|
479
|
+
clearDurable(): void {
|
|
480
|
+
this._clearDurable = true;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// ── Lifecycle ─────────────────────────────────────────────────────────────
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Called ONCE on the initial HTTP GET request.
|
|
487
|
+
* Never called on subsequent WebSocket round trips (call `this.refresh()` to force it).
|
|
488
|
+
*
|
|
489
|
+
* Receives the route {@link HttpContext} — the same argument controllers get — so
|
|
490
|
+
* dynamic-segment pages can read implicitly-bound models off `ctx.params`:
|
|
491
|
+
*
|
|
492
|
+
* override async onMount(ctx: HttpContext) {
|
|
493
|
+
* this.accountId = (ctx.params.account as Account).id;
|
|
494
|
+
* }
|
|
495
|
+
*
|
|
496
|
+
* Bound models persist across WebSocket round-trips via Flow synths (store the model on a
|
|
497
|
+
* `@locked`/`@expose` field), so they need not be re-resolved on every action.
|
|
498
|
+
*
|
|
499
|
+
* @param _ctx the route {@link HttpContext} (initial GET only).
|
|
500
|
+
* @category Lifecycle
|
|
501
|
+
*/
|
|
502
|
+
async onMount(_ctx?: HttpContext): Promise<void> {}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Called once after the invoked action method completes, just before re-rendering.
|
|
506
|
+
* Distinct from `onUpdated()`, which fires per client-driven property change.
|
|
507
|
+
*
|
|
508
|
+
* @category Lifecycle
|
|
509
|
+
*/
|
|
510
|
+
async onUpdate(): Promise<void> {}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Called at the BEGINNING of every request for this component — both the initial
|
|
514
|
+
* HTTP render and every subsequent WebSocket round-trip — after state is restored
|
|
515
|
+
* (round-trips) or `@url`/`@session` seeded (initial), but before `onMount()` /
|
|
516
|
+
* `onHydrate()`, property updates, and the action. Use for setup that must run on
|
|
517
|
+
* every request. (Livewire `boot()`.) Receives the route {@link HttpContext} — note that on
|
|
518
|
+
* WebSocket round-trips the URL is the stored route pattern, so raw `ctx.params` are
|
|
519
|
+
* only populated on the initial GET; prefer `onMount()` for reading bound models.
|
|
520
|
+
*
|
|
521
|
+
* @category Lifecycle
|
|
522
|
+
*/
|
|
523
|
+
async onBoot(_ctx?: HttpContext): Promise<void> {}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Called at the beginning of every SUBSEQUENT (WebSocket) request, right after the
|
|
527
|
+
* component is rebuilt from its snapshot — never on the initial render (use
|
|
528
|
+
* `onMount()` for that). Ideal for re-deriving non-persisted/protected state from
|
|
529
|
+
* restored properties. (Livewire `hydrate()`.)
|
|
530
|
+
*
|
|
531
|
+
* @category Lifecycle
|
|
532
|
+
*/
|
|
533
|
+
async onHydrate(): Promise<void> {}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Called at the END of every request, just before the component is serialised into
|
|
537
|
+
* its snapshot. Use to normalise state back into a serialisable shape.
|
|
538
|
+
* (Livewire `dehydrate()`.)
|
|
539
|
+
*
|
|
540
|
+
* @category Lifecycle
|
|
541
|
+
*/
|
|
542
|
+
async onDehydrate(): Promise<void> {}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Called BEFORE a client-driven property update is applied (a `value`/`checked`
|
|
546
|
+
* input or `$flow.$set`). Throw to reject the update. Only fires for writable
|
|
547
|
+
* (`@expose`) properties. For a single property, define `onUpdating<Prop>(value, key?)`
|
|
548
|
+
* — e.g. `onUpdatingEmail()`. (Livewire `updating()`.)
|
|
549
|
+
*
|
|
550
|
+
* @param prop the property about to change
|
|
551
|
+
* @param value the incoming value
|
|
552
|
+
* @param key for array properties, the element key being changed (else undefined)
|
|
553
|
+
* @category Lifecycle
|
|
554
|
+
*/
|
|
555
|
+
|
|
556
|
+
async onUpdating(_prop: string, _value: unknown, _key?: string): Promise<void> {}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Called AFTER a client-driven property update is applied. For a single property,
|
|
560
|
+
* define `onUpdated<Prop>(value, key?)` — e.g. `onUpdatedUsername()` to normalise a
|
|
561
|
+
* value such as lower-casing. (Livewire `updated()`.)
|
|
562
|
+
*
|
|
563
|
+
* @category Lifecycle
|
|
564
|
+
*/
|
|
565
|
+
|
|
566
|
+
async onUpdated(_prop: string, _value: unknown, _key?: string): Promise<void> {}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Called BEFORE `render()` runs. (Livewire `rendering()`.)
|
|
570
|
+
*
|
|
571
|
+
* @category Lifecycle
|
|
572
|
+
*/
|
|
573
|
+
async onRendering(): Promise<void> {}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Called AFTER `render()` produces this component's HTML. (Livewire `rendered()`.)
|
|
577
|
+
* @param html the rendered HTML for this component
|
|
578
|
+
* @category Lifecycle
|
|
579
|
+
*/
|
|
580
|
+
|
|
581
|
+
async onRendered(_html: string): Promise<void> {}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Called when an action throws an unhandled error. Flow catches the error and
|
|
585
|
+
* still re-renders; override to customise (log, swallow, re-flash). The default
|
|
586
|
+
* flashes the message. (Livewire `exception()`.)
|
|
587
|
+
*
|
|
588
|
+
* @param error the error thrown by the action.
|
|
589
|
+
* @category Errors
|
|
590
|
+
*/
|
|
591
|
+
async onError(error: Error): Promise<void> {
|
|
592
|
+
this.flash(error.message, "error");
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// ── Render ────────────────────────────────────────────────────────────────
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Return the JSX that represents this page.
|
|
599
|
+
* Must include a single root element with `data-flow-root`.
|
|
600
|
+
*
|
|
601
|
+
* @returns the rendered {@link HtmlNode} tree for this component.
|
|
602
|
+
* @category Rendering
|
|
603
|
+
*/
|
|
604
|
+
abstract render(): Promise<HtmlNode>;
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Return the placeholder HTML shown while a lazy/deferred component loads.
|
|
608
|
+
* Override this in child components that use `{ lazy: true }` or `{ defer: true }`.
|
|
609
|
+
*
|
|
610
|
+
* @example
|
|
611
|
+
* override placeholder() {
|
|
612
|
+
* return <div class="skeleton animate-pulse h-24 w-full rounded-lg" />;
|
|
613
|
+
* }
|
|
614
|
+
*
|
|
615
|
+
* @category Rendering
|
|
616
|
+
*/
|
|
617
|
+
placeholder(): HtmlNode {
|
|
618
|
+
return {
|
|
619
|
+
html: '<div data-flow-lazy-placeholder style="min-height:1px;"></div>',
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Wrap the rendered page root in a layout shell. `page` is the already-rendered
|
|
625
|
+
* `<div data-flow-root>…</div>` node; return whatever JSX wraps it. The default is
|
|
626
|
+
* no layout (returns `page` unchanged).
|
|
627
|
+
*
|
|
628
|
+
* This is the JSX-native alternative to `static layout = SomeLayout`: a layout is
|
|
629
|
+
* just a component you wrap the page in, and its regions are ordinary props — the
|
|
630
|
+
* same `Page.layout = (page) => <AppLayout>{page}</AppLayout>` convention the
|
|
631
|
+
* framework's React/Inertia pages already use. `static layout` still works; when
|
|
632
|
+
* both are present, this method wins.
|
|
633
|
+
*
|
|
634
|
+
* The shell renders once (on the initial GET) and stays outside the reactive root,
|
|
635
|
+
* so it is not re-rendered or re-sent on WebSocket actions and survives SPA
|
|
636
|
+
* navigation between pages that wrap with the same layout.
|
|
637
|
+
*
|
|
638
|
+
* @example
|
|
639
|
+
* override layout(page: HtmlNode) {
|
|
640
|
+
* return <AppLayout title={ProfilePage.title} actions={<Save />}>{page}</AppLayout>;
|
|
641
|
+
* }
|
|
642
|
+
*
|
|
643
|
+
* @param page the already-rendered `<div data-flow-root>` page node to wrap.
|
|
644
|
+
* @returns the wrapping shell (or `page` unchanged for no layout).
|
|
645
|
+
* @category Rendering
|
|
646
|
+
*/
|
|
647
|
+
layout(page: HtmlNode): HtmlNode | Promise<HtmlNode> {
|
|
648
|
+
return page;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* Return the HTML the parent passed for a named slot, for placing inside this
|
|
653
|
+
* component's `render()`. Call with no argument for the default slot (the child's
|
|
654
|
+
* plain `children`), or a name for a named slot (`slots={{ header: … }}` on the parent).
|
|
655
|
+
* Returns an empty node when the slot wasn't provided, so `{this.slot("footer")}`
|
|
656
|
+
* is safe to leave in the template unconditionally.
|
|
657
|
+
*
|
|
658
|
+
* Slot content is rendered in the PARENT's scope and carried in this component's
|
|
659
|
+
* snapshot, so it survives the child's own round-trips. Use `hasSlot()` to branch
|
|
660
|
+
* on whether a slot was supplied (e.g. to omit a wrapping `<header>` entirely).
|
|
661
|
+
*
|
|
662
|
+
* @example
|
|
663
|
+
* override async render() {
|
|
664
|
+
* return (
|
|
665
|
+
* <div class="card">
|
|
666
|
+
* {this.hasSlot("header") && <header>{this.slot("header")}</header>}
|
|
667
|
+
* <div class="body">{this.slot()}</div>
|
|
668
|
+
* {this.hasSlot("footer") && <footer>{this.slot("footer")}</footer>}
|
|
669
|
+
* </div>
|
|
670
|
+
* );
|
|
671
|
+
* }
|
|
672
|
+
*
|
|
673
|
+
* @param name the slot name; omit for the default slot.
|
|
674
|
+
* @returns the slot's HTML, or an empty node when the slot was not provided.
|
|
675
|
+
* @category Rendering
|
|
676
|
+
*/
|
|
677
|
+
slot(name = "default"): HtmlNode {
|
|
678
|
+
return { html: this._flowSlots[name] ?? "" };
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* True when the parent supplied (non-empty) content for the given slot.
|
|
683
|
+
* @category Rendering
|
|
684
|
+
*/
|
|
685
|
+
hasSlot(name = "default"): boolean {
|
|
686
|
+
const html = this._flowSlots[name];
|
|
687
|
+
return typeof html === "string" && html.length > 0;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// ── Action helpers ────────────────────────────────────────────────────────
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* Emit a flash notification to the client.
|
|
694
|
+
*
|
|
695
|
+
* Returns a {@link FlashBuilder} so the toast can be configured fluently. The
|
|
696
|
+
* second argument may also be a level string (back-compat) or a
|
|
697
|
+
* {@link FlashOptions} object (for dynamic config):
|
|
698
|
+
*
|
|
699
|
+
* @example
|
|
700
|
+
* this.flash("Saved"); // success toast
|
|
701
|
+
* this.flash("Bad input", "error"); // level shorthand
|
|
702
|
+
* this.flash("Saved", { type: "success", duration }); // options object
|
|
703
|
+
* this.flash("Post deleted").warning().duration(8000).progressBar();
|
|
704
|
+
*
|
|
705
|
+
* @param message the toast text.
|
|
706
|
+
* @param optionsOrLevel a {@link FlashLevel} shorthand or a {@link FlashOptions} object.
|
|
707
|
+
* @returns a {@link FlashBuilder} for fluent configuration.
|
|
708
|
+
* @category Actions
|
|
709
|
+
*/
|
|
710
|
+
flash(message: string, optionsOrLevel: FlashLevel | FlashOptions = "success"): FlashBuilder {
|
|
711
|
+
const payload: FlashMessage =
|
|
712
|
+
typeof optionsOrLevel === "string"
|
|
713
|
+
? { message, level: optionsOrLevel }
|
|
714
|
+
: { message, level: optionsOrLevel.level ?? optionsOrLevel.type ?? "success" };
|
|
715
|
+
|
|
716
|
+
// Copy through any options provided on the object form.
|
|
717
|
+
if (typeof optionsOrLevel === "object") {
|
|
718
|
+
const o = optionsOrLevel;
|
|
719
|
+
if (o.title !== undefined) payload.title = o.title;
|
|
720
|
+
if (o.position !== undefined) payload.position = o.position;
|
|
721
|
+
if (o.duration !== undefined) payload.duration = o.duration;
|
|
722
|
+
if (o.dismissible !== undefined) payload.dismissible = o.dismissible;
|
|
723
|
+
if (o.icon !== undefined) payload.icon = o.icon;
|
|
724
|
+
if (o.progressBar !== undefined) payload.progressBar = o.progressBar;
|
|
725
|
+
if (o.actions !== undefined) payload.actions = o.actions;
|
|
726
|
+
if (o.onClose !== undefined) payload.onClose = o.onClose;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
this._flashes.push(payload);
|
|
730
|
+
|
|
731
|
+
// The builder mutates the queued payload in place — `_drainEffects` reads it
|
|
732
|
+
// after the action returns, so chained setters land before it's serialized.
|
|
733
|
+
const builder: FlashBuilder = {
|
|
734
|
+
level: (l) => ((payload.level = l), builder),
|
|
735
|
+
success: () => ((payload.level = "success"), builder),
|
|
736
|
+
error: () => ((payload.level = "error"), builder),
|
|
737
|
+
warning: () => ((payload.level = "warning"), builder),
|
|
738
|
+
info: () => ((payload.level = "info"), builder),
|
|
739
|
+
title: (t) => ((payload.title = t), builder),
|
|
740
|
+
position: (p) => ((payload.position = p), builder),
|
|
741
|
+
duration: (ms) => ((payload.duration = ms), builder),
|
|
742
|
+
noAutoDismiss: () => ((payload.duration = 0), builder),
|
|
743
|
+
dismissible: (v = true) => ((payload.dismissible = v), builder),
|
|
744
|
+
icon: (i) => ((payload.icon = i), builder),
|
|
745
|
+
progressBar: (v = true) => ((payload.progressBar = v), builder),
|
|
746
|
+
action: (label, method, args, style) => {
|
|
747
|
+
const s: FlashActionStyle = typeof style === "string" ? { color: style } : (style ?? {});
|
|
748
|
+
(payload.actions ??= []).push({
|
|
749
|
+
label,
|
|
750
|
+
method,
|
|
751
|
+
...(args ? { args } : {}),
|
|
752
|
+
...(s.color ? { color: s.color } : {}),
|
|
753
|
+
...(s.variant ? { variant: s.variant } : {}),
|
|
754
|
+
...(s.uppercase ? { uppercase: true } : {}),
|
|
755
|
+
});
|
|
756
|
+
return builder;
|
|
757
|
+
},
|
|
758
|
+
onClose: (method, args) => (
|
|
759
|
+
(payload.onClose = { method, ...(args ? { args } : {}) }),
|
|
760
|
+
builder
|
|
761
|
+
),
|
|
762
|
+
};
|
|
763
|
+
return builder;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/**
|
|
767
|
+
* Redirect the client to `url` after the current action completes.
|
|
768
|
+
*
|
|
769
|
+
* Returns a builder so a flash can be chained (mirrors the HTTP ResponseBuilder):
|
|
770
|
+
*
|
|
771
|
+
* @example
|
|
772
|
+
* this.redirect("/dashboard");
|
|
773
|
+
* return this.redirect(next || "/", 303).withSuccess("Welcome back.");
|
|
774
|
+
* return this.redirect("/login").withError("Please sign in.");
|
|
775
|
+
*
|
|
776
|
+
* @param url Destination URL.
|
|
777
|
+
* @param status Optional HTTP-style status (advisory — the client navigates regardless).
|
|
778
|
+
* @returns a {@link RedirectFlash} builder for chaining a flash onto the redirect.
|
|
779
|
+
* @category Navigation & redirects
|
|
780
|
+
*/
|
|
781
|
+
redirect(url: string, status?: number): RedirectFlash {
|
|
782
|
+
this._redirectUrl = url;
|
|
783
|
+
this._redirectStatus = status ?? null;
|
|
784
|
+
const self = this;
|
|
785
|
+
const builder: RedirectFlash = {
|
|
786
|
+
with(message: string, level: FlashLevel) {
|
|
787
|
+
self.flash(message, level);
|
|
788
|
+
return this;
|
|
789
|
+
},
|
|
790
|
+
withSuccess(message: string) {
|
|
791
|
+
return this.with(message, "success");
|
|
792
|
+
},
|
|
793
|
+
withError(message: string) {
|
|
794
|
+
return this.with(message, "error");
|
|
795
|
+
},
|
|
796
|
+
withInfo(message: string) {
|
|
797
|
+
return this.with(message, "info");
|
|
798
|
+
},
|
|
799
|
+
withWarning(message: string) {
|
|
800
|
+
return this.with(message, "warning");
|
|
801
|
+
},
|
|
802
|
+
};
|
|
803
|
+
return builder;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/**
|
|
807
|
+
* Redirect to a named route, mirroring Livewire's `redirectRoute('profile', ['id' => 1])`.
|
|
808
|
+
* Route params fill `:segment` placeholders; any extra keys become query-string params.
|
|
809
|
+
*
|
|
810
|
+
* @example
|
|
811
|
+
* return this.redirectRoute("profile", { id: 1 }).withSuccess("Saved.");
|
|
812
|
+
* this.redirectRoute("posts.show", { slug: "hello", ref: "email" }); // /posts/hello?ref=email
|
|
813
|
+
*
|
|
814
|
+
* @param name The route name registered with `.name(...)`.
|
|
815
|
+
* @param params Route + query params.
|
|
816
|
+
* @param status Optional HTTP-style status (advisory — the client navigates regardless).
|
|
817
|
+
* @returns a {@link RedirectFlash} builder for chaining a flash onto the redirect.
|
|
818
|
+
* @category Navigation & redirects
|
|
819
|
+
*/
|
|
820
|
+
redirectRoute(
|
|
821
|
+
name: string,
|
|
822
|
+
params: Record<string, string | number> = {},
|
|
823
|
+
status?: number,
|
|
824
|
+
): RedirectFlash {
|
|
825
|
+
return this.redirect(route(name, params), status);
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/**
|
|
829
|
+
* Redirect the user back to where they were headed before being intercepted — Livewire's
|
|
830
|
+
* `redirectIntended('/default/url')`. Reads (and clears) the `intended_url` stored in the
|
|
831
|
+
* session by `AuthMiddleware`, falling back to `fallback` when none is stored or the stored
|
|
832
|
+
* URL is cross-origin (open-redirect guard).
|
|
833
|
+
*
|
|
834
|
+
* @example
|
|
835
|
+
* if (await Auth.attempt(creds)) return this.redirectIntended("/dashboard");
|
|
836
|
+
*
|
|
837
|
+
* @param fallback URL to use when no intended URL is available. Defaults to `/`.
|
|
838
|
+
* @param status Optional HTTP-style status (advisory — the client navigates regardless).
|
|
839
|
+
* @returns a {@link RedirectFlash} builder for chaining a flash onto the redirect.
|
|
840
|
+
* @category Navigation & redirects
|
|
841
|
+
*/
|
|
842
|
+
redirectIntended(fallback = "/", status?: number): RedirectFlash {
|
|
843
|
+
const ctx = request() as unknown as {
|
|
844
|
+
session?: { get<T>(k: string): T | undefined; forget(k: string): void };
|
|
845
|
+
url?: { origin: string };
|
|
846
|
+
};
|
|
847
|
+
const stored = ctx.session?.get<string>("intended_url");
|
|
848
|
+
if (stored) ctx.session?.forget("intended_url");
|
|
849
|
+
const target = safeRedirectPath(stored, ctx.url?.origin ?? "") ?? fallback;
|
|
850
|
+
return this.redirect(target, status);
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/**
|
|
854
|
+
* Force `onMount()` to run again on the CURRENT WebSocket round trip.
|
|
855
|
+
* Use this to reload external data without requiring a full page reload.
|
|
856
|
+
*
|
|
857
|
+
* @category Actions
|
|
858
|
+
*/
|
|
859
|
+
async refresh(): Promise<void> {
|
|
860
|
+
this._shouldRefresh = true;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
/**
|
|
864
|
+
* Queue a raw JavaScript expression to be evaluated in the browser
|
|
865
|
+
* after the current action's DOM patch is applied.
|
|
866
|
+
*
|
|
867
|
+
* The expression runs in the Alpine context of the component root element:
|
|
868
|
+
* `$el`, `$refs`, and other Alpine magic properties are all available.
|
|
869
|
+
*
|
|
870
|
+
* Multiple calls are batched and executed in order after the single round trip.
|
|
871
|
+
*
|
|
872
|
+
* @example
|
|
873
|
+
* this.client(`$refs.titleInput.focus()`);
|
|
874
|
+
* this.client(`$el.querySelector('.toast').classList.add('visible')`);
|
|
875
|
+
*
|
|
876
|
+
* @security Never interpolate unescaped user input into the expression string.
|
|
877
|
+
* @category Actions
|
|
878
|
+
*/
|
|
879
|
+
client(script: string): void {
|
|
880
|
+
this._clientScripts.push(script);
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/**
|
|
884
|
+
* Gets the current title value.
|
|
885
|
+
* @category Actions
|
|
886
|
+
*/
|
|
887
|
+
title(): string;
|
|
888
|
+
|
|
889
|
+
/**
|
|
890
|
+
* Update `document.title` in the browser after this action completes.
|
|
891
|
+
*
|
|
892
|
+
* @example
|
|
893
|
+
* this.title(`Search: ${this.query}`);
|
|
894
|
+
*
|
|
895
|
+
* @category Actions
|
|
896
|
+
*/
|
|
897
|
+
title(value: string): void;
|
|
898
|
+
|
|
899
|
+
// Implementation signature (hidden from the public API)
|
|
900
|
+
title(value?: string): void | string {
|
|
901
|
+
if (value === undefined) {
|
|
902
|
+
return this._titleValue ?? (this.constructor as { title?: string }).title ?? "";
|
|
903
|
+
}
|
|
904
|
+
this._titleValue = value;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// ── Validation ────────────────────────────────────────────────────────────
|
|
908
|
+
|
|
909
|
+
/**
|
|
910
|
+
* Validate page properties against rules. If validation fails, errors are
|
|
911
|
+
* stored and a `ValidationError` is thrown (caught by the framework, which
|
|
912
|
+
* re-renders the page with `$errors` populated client-side).
|
|
913
|
+
*
|
|
914
|
+
* Pass explicit rules or omit to use `@validate` decorator rules.
|
|
915
|
+
*
|
|
916
|
+
* **Error persistence:** validation errors survive across actions and are
|
|
917
|
+
* re-sent on every patch until the next `validate()` call clears them (on
|
|
918
|
+
* success) or `resetValidation()` is called explicitly. If you want errors
|
|
919
|
+
* cleared at the start of every action, call `this.resetValidation()` before
|
|
920
|
+
* the new `validate()` call.
|
|
921
|
+
*
|
|
922
|
+
* @example
|
|
923
|
+
* async save() {
|
|
924
|
+
* this.validate({
|
|
925
|
+
* email: (rule) => rule.required().email(),
|
|
926
|
+
* password: (rule) => rule.required().min(8),
|
|
927
|
+
* });
|
|
928
|
+
* // reaches here only if valid
|
|
929
|
+
* }
|
|
930
|
+
*
|
|
931
|
+
* @param rulesOrForm explicit per-field rules, a {@link Form} instance to validate, or
|
|
932
|
+
* omit to use the component's `@validate` decorator rules.
|
|
933
|
+
* @throws {ValidationError} when validation fails — the framework catches it and re-renders
|
|
934
|
+
* with `this.errors` populated.
|
|
935
|
+
* @category Validation
|
|
936
|
+
*/
|
|
937
|
+
async validate(
|
|
938
|
+
rulesOrForm?:
|
|
939
|
+
ValidationRules | { __isFlowForm: true; _validateToErrors(): Record<string, string[]> },
|
|
940
|
+
): Promise<void> {
|
|
941
|
+
// Validating a Form object: delegate to its @zerotal/validator schema.
|
|
942
|
+
if (rulesOrForm && typeof rulesOrForm === "object" && "__isFlowForm" in rulesOrForm) {
|
|
943
|
+
const errors = (
|
|
944
|
+
rulesOrForm as { _validateToErrors(): Record<string, string[]> }
|
|
945
|
+
)._validateToErrors();
|
|
946
|
+
if (Object.keys(errors).length > 0) {
|
|
947
|
+
this._errors = errors;
|
|
948
|
+
throw new ValidationError(errors);
|
|
949
|
+
}
|
|
950
|
+
this._errors = {};
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// Use explicit per-field rules, or fall back to the @validate decorator rules. Both are
|
|
955
|
+
// builders over @zerotal/validator's RuleBuilder — Flow delegates the engine.
|
|
956
|
+
const builders: Map<string, ValidateBuilder> =
|
|
957
|
+
rulesOrForm !== undefined
|
|
958
|
+
? new Map(Object.entries(rulesOrForm as ValidationRules))
|
|
959
|
+
: getValidateRules(this);
|
|
960
|
+
|
|
961
|
+
const schema = this._buildSchema(builders);
|
|
962
|
+
|
|
963
|
+
// Validate against the full exposed-props data bag (so cross-field rules like `confirmed`
|
|
964
|
+
// can see the other fields).
|
|
965
|
+
const outcome = await runValidationAsync(schema, this._exposedData());
|
|
966
|
+
|
|
967
|
+
if (!outcome.success) {
|
|
968
|
+
this._errors = _toErrorBag(outcome.errors);
|
|
969
|
+
throw new ValidationError(this._errors);
|
|
970
|
+
}
|
|
971
|
+
this._errors = {};
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
/** @internal Build a validator `Schema` from a field→builder map. */
|
|
975
|
+
_buildSchema(builders: Map<string, ValidateBuilder>): Schema {
|
|
976
|
+
const schema: Schema = {};
|
|
977
|
+
const rule = new RuleBuilder();
|
|
978
|
+
for (const [field, build] of builders) {
|
|
979
|
+
schema[field] = build(rule)._def as FieldRuleDefinition;
|
|
980
|
+
}
|
|
981
|
+
return schema;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
/** @internal Snapshot of every exposed prop's current value, for validation input. */
|
|
985
|
+
_exposedData(): Record<string, unknown> {
|
|
986
|
+
const data: Record<string, unknown> = {};
|
|
987
|
+
for (const key of getExposedProps(this)) {
|
|
988
|
+
data[key] = (this as Record<string, unknown>)[key];
|
|
989
|
+
}
|
|
990
|
+
return data;
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
/**
|
|
994
|
+
* @internal Real-time validation of a SINGLE field against its `@validate` rule (Livewire's
|
|
995
|
+
* live validation). Sets or clears `this._errors[field]` without throwing, so it can run on
|
|
996
|
+
* every client update. No-op for fields without a `@validate` rule.
|
|
997
|
+
*/
|
|
998
|
+
async _validateField(field: string): Promise<void> {
|
|
999
|
+
const build = getValidateRules(this).get(field);
|
|
1000
|
+
if (!build) return;
|
|
1001
|
+
|
|
1002
|
+
const schema = this._buildSchema(new Map([[field, build]]));
|
|
1003
|
+
const outcome = await runValidationAsync(schema, this._exposedData());
|
|
1004
|
+
|
|
1005
|
+
if (!outcome.success && outcome.errors[field]) {
|
|
1006
|
+
this._errors[field] = [outcome.errors[field]];
|
|
1007
|
+
} else {
|
|
1008
|
+
delete this._errors[field];
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
/**
|
|
1013
|
+
* Manually add a validation error for a field.
|
|
1014
|
+
*
|
|
1015
|
+
* @example
|
|
1016
|
+
* this.addError('email', 'That email is already taken.');
|
|
1017
|
+
*
|
|
1018
|
+
* @category Validation
|
|
1019
|
+
*/
|
|
1020
|
+
addError(field: string, message: string): void {
|
|
1021
|
+
if (!this._errors[field]) this._errors[field] = [];
|
|
1022
|
+
this._errors[field]!.push(message);
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
/**
|
|
1026
|
+
* Clear validation errors — all fields, or just the specified field.
|
|
1027
|
+
*
|
|
1028
|
+
* @example
|
|
1029
|
+
* this.resetValidation(); // clear all
|
|
1030
|
+
* this.resetValidation('email'); // clear email only
|
|
1031
|
+
*
|
|
1032
|
+
* @category Validation
|
|
1033
|
+
*/
|
|
1034
|
+
resetValidation(field?: string): void {
|
|
1035
|
+
if (field) {
|
|
1036
|
+
delete this._errors[field];
|
|
1037
|
+
} else {
|
|
1038
|
+
this._errors = {};
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
// ── Cross-component events ────────────────────────────────────────────────
|
|
1043
|
+
|
|
1044
|
+
/**
|
|
1045
|
+
* Dispatch a named event to all other Flow components on the page.
|
|
1046
|
+
* Components with a matching `@on('event-name')` decorator will have
|
|
1047
|
+
* their listener method called automatically.
|
|
1048
|
+
*
|
|
1049
|
+
* @example
|
|
1050
|
+
* async save() {
|
|
1051
|
+
* const post = await Post.create(this.form);
|
|
1052
|
+
* this.dispatch('post-created', { id: post.id });
|
|
1053
|
+
* }
|
|
1054
|
+
*
|
|
1055
|
+
* @category Events
|
|
1056
|
+
*/
|
|
1057
|
+
dispatch<K extends string>(
|
|
1058
|
+
name: K,
|
|
1059
|
+
...args: K extends EventName ? EventArgs<FlowEvents[K]> : [payload?: Record<string, unknown>]
|
|
1060
|
+
): void;
|
|
1061
|
+
dispatch(name: string, data: unknown = {}): void {
|
|
1062
|
+
_validateEventPayload(name, data);
|
|
1063
|
+
this._events.push({ name, data: (data ?? {}) as Record<string, unknown> });
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
/**
|
|
1067
|
+
* Dispatch an event ONLY to components of a given class name (skips all others
|
|
1068
|
+
* listening for the same event). Mirrors Livewire's `dispatch()->to(...)`.
|
|
1069
|
+
*
|
|
1070
|
+
* @example
|
|
1071
|
+
* this.dispatchTo('Dashboard', 'post-created', { id: post.id });
|
|
1072
|
+
*
|
|
1073
|
+
* @category Events
|
|
1074
|
+
*/
|
|
1075
|
+
dispatchTo<K extends string>(
|
|
1076
|
+
component: string,
|
|
1077
|
+
name: K,
|
|
1078
|
+
...args: K extends EventName ? EventArgs<FlowEvents[K]> : [payload?: Record<string, unknown>]
|
|
1079
|
+
): void;
|
|
1080
|
+
dispatchTo(component: string, name: string, data: unknown = {}): void {
|
|
1081
|
+
_validateEventPayload(name, data);
|
|
1082
|
+
this._events.push({ name, data: (data ?? {}) as Record<string, unknown>, to: component });
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
/**
|
|
1086
|
+
* Dispatch an event only to THIS component (it won't bubble to others).
|
|
1087
|
+
* Mirrors Livewire's `dispatch()->self()`.
|
|
1088
|
+
*
|
|
1089
|
+
* @example
|
|
1090
|
+
* this.dispatchSelf('refresh');
|
|
1091
|
+
*
|
|
1092
|
+
* @category Events
|
|
1093
|
+
*/
|
|
1094
|
+
dispatchSelf<K extends string>(
|
|
1095
|
+
name: K,
|
|
1096
|
+
...args: K extends EventName ? EventArgs<FlowEvents[K]> : [payload?: Record<string, unknown>]
|
|
1097
|
+
): void;
|
|
1098
|
+
dispatchSelf(name: string, data: unknown = {}): void {
|
|
1099
|
+
_validateEventPayload(name, data);
|
|
1100
|
+
this._events.push({ name, data: (data ?? {}) as Record<string, unknown>, self: true });
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
// ── Client magics ───────────────────────────────────────────────────────────
|
|
1104
|
+
// `dispatch` / `dispatchTo` / `dispatchSelf` / `refresh` above are real methods, so
|
|
1105
|
+
// they're typesafe in both server actions AND client expressions (the `this.`→`$flow.`
|
|
1106
|
+
// rewrite resolves them to the matching client magic at runtime).
|
|
1107
|
+
//
|
|
1108
|
+
// Every OTHER client magic lives on the global `$flow` object, NOT on the component. The rule:
|
|
1109
|
+
// `this.name` is always YOUR member; `$flow.name` is always a framework helper — so the bare
|
|
1110
|
+
// names (`set`, `toggle`, `store`, `parent`, `on`, `watch`, `get`, `call`, …) stay free for
|
|
1111
|
+
// your own props and methods. Write the helpers bare; the AOT compiler rewrites them to their
|
|
1112
|
+
// `$`-form for the runtime:
|
|
1113
|
+
//
|
|
1114
|
+
// onClick={() => $flow.set("open", true)} // write + sync an @expose prop
|
|
1115
|
+
// onClick={() => $flow.toggle("open")}
|
|
1116
|
+
// onClick={() => $flow.parent.save()} // call a parent action
|
|
1117
|
+
// onClick={() => ($flow.store.ui.dark = true)} // global client store
|
|
1118
|
+
// $flow.get / $flow.call / $flow.on / $flow.watch / $flow.whisper / $flow.onWhisper /
|
|
1119
|
+
// $flow.cancel / $flow.appendOptimistic / $flow.removeOptimistic / $flow.commit
|
|
1120
|
+
//
|
|
1121
|
+
// `$flow` is typed globally (see augment.ts); `$flow.store` is typed by `FlowStore`.
|
|
1122
|
+
|
|
1123
|
+
/**
|
|
1124
|
+
* Build a URL from the current one with merged query params — client-only. Use it in a
|
|
1125
|
+
* JSX expression: `href={this.currentUrl({ query: this.q })}`, `class={this.currentUrl() === "/" ? "on" : ""}`,
|
|
1126
|
+
* or `{this.currentUrl(...)}`. The compiler rewrites `this.currentUrl` → `$flow.currentUrl`
|
|
1127
|
+
* so it runs on the client. It never runs on the server — hitting this throw means the call
|
|
1128
|
+
* reached server code (e.g. a page the compiler couldn't statically compile, or a server action).
|
|
1129
|
+
*
|
|
1130
|
+
* @throws {Error} when executed on the server (it is meant to be compiled to `$flow.currentUrl`).
|
|
1131
|
+
* @category Navigation & redirects
|
|
1132
|
+
*/
|
|
1133
|
+
currentUrl(_options?: CurrentUrlOptions): string {
|
|
1134
|
+
return _clientOnlyUrlHelper("currentUrl");
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
/**
|
|
1138
|
+
* Build the URL as {@link currentUrl} does, then SPA-navigate to it — client-only. Use it in a
|
|
1139
|
+
* client handler: `onClick={() => this.navigateCurrent({ query: { page: 2 } })}`. The compiler
|
|
1140
|
+
* rewrites it to `$flow.navigateCurrent`. To navigate from server code, return a `redirect()`.
|
|
1141
|
+
*
|
|
1142
|
+
* @throws {Error} when executed on the server (it is meant to be compiled to `$flow.navigateCurrent`).
|
|
1143
|
+
* @category Navigation & redirects
|
|
1144
|
+
*/
|
|
1145
|
+
navigateCurrent(_options?: CurrentUrlOptions): Promise<void> {
|
|
1146
|
+
return _clientOnlyUrlHelper("navigateCurrent");
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
// ── File downloads ────────────────────────────────────────────────────────
|
|
1150
|
+
|
|
1151
|
+
/**
|
|
1152
|
+
* Trigger a browser file download.
|
|
1153
|
+
*
|
|
1154
|
+
* `content` is either text (CSV, JSON, an SVG) or the raw bytes of a binary
|
|
1155
|
+
* file. Text is encoded as UTF-8; bytes are sent exactly as given, which is
|
|
1156
|
+
* what a format like a spreadsheet or a PDF requires — passing those through
|
|
1157
|
+
* a string would re-encode every byte above 127 and corrupt the file.
|
|
1158
|
+
*
|
|
1159
|
+
* @example
|
|
1160
|
+
* async export() {
|
|
1161
|
+
* const csv = this.buildCsv(this.items);
|
|
1162
|
+
* this.download('export.csv', csv, 'text/csv;charset=utf-8');
|
|
1163
|
+
* }
|
|
1164
|
+
*
|
|
1165
|
+
* @param filename the download's suggested file name.
|
|
1166
|
+
* @param content text, or the file's raw bytes (base64-encoded to travel).
|
|
1167
|
+
* @param mime the MIME type. Defaults to `application/octet-stream`.
|
|
1168
|
+
* @category Actions
|
|
1169
|
+
*/
|
|
1170
|
+
download(
|
|
1171
|
+
filename: string,
|
|
1172
|
+
content: string | Uint8Array,
|
|
1173
|
+
mime = "application/octet-stream",
|
|
1174
|
+
): void {
|
|
1175
|
+
const encoded = Buffer.from(content as Uint8Array).toString("base64");
|
|
1176
|
+
this._downloads.push({ filename, content: encoded, mime });
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
// ── Two-way model binding ─────────────────────────────────────────────────
|
|
1180
|
+
|
|
1181
|
+
/**
|
|
1182
|
+
* Emit the HTML attributes needed for `flow:model` two-way binding.
|
|
1183
|
+
*
|
|
1184
|
+
* @example
|
|
1185
|
+
* <input {...this.bind('name')} />
|
|
1186
|
+
* // → <input flow:model="name" value="Alice" />
|
|
1187
|
+
*
|
|
1188
|
+
* @param key the `@expose` property to two-way bind.
|
|
1189
|
+
* @returns the `flow:model` + `value` attributes to spread onto an input.
|
|
1190
|
+
* @category State & exposure
|
|
1191
|
+
*/
|
|
1192
|
+
bind(key: string): Record<string, unknown> {
|
|
1193
|
+
return {
|
|
1194
|
+
"flow:model": key,
|
|
1195
|
+
value: (this as Record<string, unknown>)[key] ?? "",
|
|
1196
|
+
};
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
// ── Nested components ─────────────────────────────────────────────────────
|
|
1200
|
+
|
|
1201
|
+
/**
|
|
1202
|
+
* Embed another Component as a nested component with its own isolated state,
|
|
1203
|
+
* its own WebSocket update cycle, and its own snapshot.
|
|
1204
|
+
*
|
|
1205
|
+
* Children are islands: a parent re-render does NOT re-render existing
|
|
1206
|
+
* children (their DOM and state are preserved on the client), and a child
|
|
1207
|
+
* update never touches the parent. Use `key` when embedding the same
|
|
1208
|
+
* component class multiple times (e.g. in a loop).
|
|
1209
|
+
*
|
|
1210
|
+
* `props` are assigned to the child instance before `onMount()` runs.
|
|
1211
|
+
*
|
|
1212
|
+
* Pass `lazy: true` to use IntersectionObserver — `onMount()` is deferred
|
|
1213
|
+
* until the placeholder enters the viewport.
|
|
1214
|
+
* Pass `defer: true` to load immediately after page paint (no intersection check).
|
|
1215
|
+
*
|
|
1216
|
+
* @example
|
|
1217
|
+
* override async render() {
|
|
1218
|
+
* return <div>
|
|
1219
|
+
* <h1>Dashboard</h1>
|
|
1220
|
+
* <StatsWidget />
|
|
1221
|
+
* <CounterWidget key="a" step={5} />
|
|
1222
|
+
* <SlowWidget lazy />
|
|
1223
|
+
* </div>;
|
|
1224
|
+
* }
|
|
1225
|
+
*
|
|
1226
|
+
* @param ChildClass the child Component class to embed.
|
|
1227
|
+
* @param opts `key` disambiguates repeated instances; `props` seed the child before
|
|
1228
|
+
* `onMount()`; `lazy`/`defer` render a placeholder first; `slots` pass named
|
|
1229
|
+
* slot HTML.
|
|
1230
|
+
* @returns the child's rendered {@link HtmlNode} (root or placeholder).
|
|
1231
|
+
* @category Rendering
|
|
1232
|
+
*/
|
|
1233
|
+
async child<C extends Component>(
|
|
1234
|
+
ChildClass: new () => C,
|
|
1235
|
+
opts: {
|
|
1236
|
+
key?: string | number;
|
|
1237
|
+
props?: Partial<C>;
|
|
1238
|
+
lazy?: boolean;
|
|
1239
|
+
defer?: boolean;
|
|
1240
|
+
/** Named slot HTML (`name → html`, default slot keyed `"default"`), rendered by the parent. */
|
|
1241
|
+
slots?: Record<string, string>;
|
|
1242
|
+
} = {},
|
|
1243
|
+
): Promise<HtmlNode> {
|
|
1244
|
+
const name = ChildClass.name;
|
|
1245
|
+
const base = `${this._flowId}-${name.toLowerCase()}-`;
|
|
1246
|
+
// Deterministic id: explicit key, or occurrence index within this render.
|
|
1247
|
+
const keyPart =
|
|
1248
|
+
opts.key !== undefined
|
|
1249
|
+
? String(opts.key).replace(/[^a-zA-Z0-9_-]/g, "")
|
|
1250
|
+
: String(this._childIds.filter((id) => id.startsWith(base)).length);
|
|
1251
|
+
const childId = `${base}${keyPart}`;
|
|
1252
|
+
|
|
1253
|
+
this._childIds.push(childId);
|
|
1254
|
+
|
|
1255
|
+
// Lazy-imported to keep Component.ts free of heavy static deps at class-load time.
|
|
1256
|
+
const { dehydrate } = await import("./dehydrate.ts");
|
|
1257
|
+
const { registerComponent } = await import("./registry.ts");
|
|
1258
|
+
registerComponent(ChildClass as never, this._flowPath);
|
|
1259
|
+
|
|
1260
|
+
// ── Reactive / modelable bindings (Tier 1) ───────────────────────────────
|
|
1261
|
+
// `data-flow-props` carries the current value of each @reactive prop so the
|
|
1262
|
+
// client can detect a parent-pushed change and re-render the child. For each
|
|
1263
|
+
// @modelable prop, `data-flow-model` maps the child prop → the parent property
|
|
1264
|
+
// it syncs back to (resolved by value identity against the parent's props).
|
|
1265
|
+
const childProto = (ChildClass as { prototype: object }).prototype;
|
|
1266
|
+
const reactiveKeys = getReactiveProps(childProto);
|
|
1267
|
+
const modelableKeys = getModelableProps(childProto);
|
|
1268
|
+
const givenProps = (opts.props ?? {}) as Record<string, unknown>;
|
|
1269
|
+
let bindAttrs = "";
|
|
1270
|
+
if (reactiveKeys.size) {
|
|
1271
|
+
const propsObj: Record<string, unknown> = {};
|
|
1272
|
+
for (const k of reactiveKeys) if (k in givenProps) propsObj[k] = givenProps[k];
|
|
1273
|
+
if (Object.keys(propsObj).length) {
|
|
1274
|
+
bindAttrs += ` data-flow-props='${_attrJson(JSON.stringify(propsObj))}'`;
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
if (modelableKeys.size) {
|
|
1278
|
+
const modelMap: Record<string, string> = {};
|
|
1279
|
+
const exposed = getExposedProps(this);
|
|
1280
|
+
const locked = getLockedProps(this);
|
|
1281
|
+
for (const mProp of modelableKeys) {
|
|
1282
|
+
const val = givenProps[mProp];
|
|
1283
|
+
for (const pk of exposed) {
|
|
1284
|
+
if (locked.has(pk)) continue;
|
|
1285
|
+
if (Object.is((this as Record<string, unknown>)[pk], val)) {
|
|
1286
|
+
modelMap[mProp] = pk;
|
|
1287
|
+
break;
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
if (Object.keys(modelMap).length) {
|
|
1292
|
+
bindAttrs += ` data-flow-model='${_attrJson(JSON.stringify(modelMap))}'`;
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
// Parent re-render over WS: children that already exist client-side are
|
|
1297
|
+
// emitted as stubs — the client morph skips nested roots, so the child's live
|
|
1298
|
+
// DOM and state are preserved (Livewire pattern). The stub still carries
|
|
1299
|
+
// data-flow-props so reactive/modelable updates reach the existing child.
|
|
1300
|
+
if (this._isHydrated && this._prevChildIds.includes(childId)) {
|
|
1301
|
+
return {
|
|
1302
|
+
html: `<div data-flow-root x-data="{}" data-flow-id="${childId}" data-flow-name="${name}"${bindAttrs}></div>`,
|
|
1303
|
+
};
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
// Lazy / defer: render placeholder without calling onMount(). The client
|
|
1307
|
+
// will dispatch a $mount frame when ready (viewport entry or DOMContentLoaded).
|
|
1308
|
+
if (opts.lazy || opts.defer) {
|
|
1309
|
+
const childPage = new ChildClass();
|
|
1310
|
+
childPage._flowId = childId;
|
|
1311
|
+
childPage._flowPath = this._flowPath;
|
|
1312
|
+
if (opts.slots) childPage._flowSlots = opts.slots;
|
|
1313
|
+
if (opts.props) {
|
|
1314
|
+
for (const [k, v] of Object.entries(opts.props)) {
|
|
1315
|
+
if (!k.startsWith("_")) (childPage as Record<string, unknown>)[k] = v;
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
// Don't call onMount() — placeholder only
|
|
1319
|
+
|
|
1320
|
+
const placeholderNode = childPage.placeholder();
|
|
1321
|
+
const snapshot = dehydrate(childPage, {
|
|
1322
|
+
id: childId,
|
|
1323
|
+
name,
|
|
1324
|
+
path: this._flowPath,
|
|
1325
|
+
});
|
|
1326
|
+
|
|
1327
|
+
const loadAttr = opts.lazy ? "data-flow-lazy" : "data-flow-defer";
|
|
1328
|
+
return {
|
|
1329
|
+
html:
|
|
1330
|
+
`<div data-flow-root x-data="{}" data-flow-id="${childId}" data-flow-name="${name}" ${loadAttr}${bindAttrs}>` +
|
|
1331
|
+
`${placeholderNode.html}` +
|
|
1332
|
+
`<script type="application/json" id="flow-state-${childId}">${toScriptJson(snapshot)}</script>` +
|
|
1333
|
+
`</div>`,
|
|
1334
|
+
};
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
// Initial render (SSR) or a child newly added by this update: full render.
|
|
1338
|
+
const childPage = new ChildClass();
|
|
1339
|
+
childPage._flowId = childId;
|
|
1340
|
+
childPage._flowPath = this._flowPath;
|
|
1341
|
+
if (opts.slots) childPage._flowSlots = opts.slots;
|
|
1342
|
+
if (opts.props) {
|
|
1343
|
+
for (const [k, v] of Object.entries(opts.props)) {
|
|
1344
|
+
if (!k.startsWith("_")) (childPage as Record<string, unknown>)[k] = v;
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
// A child is populated from its parent's props (assigned above), not from route segments,
|
|
1348
|
+
// but it still gets the request context — the same object the routed page received.
|
|
1349
|
+
const ctx = HttpContext.tryGet();
|
|
1350
|
+
await childPage.onBoot(ctx);
|
|
1351
|
+
await childPage.onMount(ctx);
|
|
1352
|
+
|
|
1353
|
+
const innerHtml = await _renderFlowPage(childPage, () => childPage.render());
|
|
1354
|
+
|
|
1355
|
+
await childPage.onDehydrate();
|
|
1356
|
+
const snapshot = dehydrate(childPage, {
|
|
1357
|
+
id: childId,
|
|
1358
|
+
name,
|
|
1359
|
+
path: this._flowPath,
|
|
1360
|
+
});
|
|
1361
|
+
|
|
1362
|
+
return {
|
|
1363
|
+
html:
|
|
1364
|
+
`<div data-flow-root x-data="{}" data-flow-id="${childId}" data-flow-name="${name}"${bindAttrs}>` +
|
|
1365
|
+
`${innerHtml}` +
|
|
1366
|
+
`<script type="application/json" id="flow-state-${childId}">${toScriptJson(snapshot)}</script>` +
|
|
1367
|
+
`</div>`,
|
|
1368
|
+
};
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
// ── Streaming ─────────────────────────────────────────────────────────────
|
|
1372
|
+
|
|
1373
|
+
/**
|
|
1374
|
+
* Push content to the client MID-ACTION — before the final patch — into any
|
|
1375
|
+
* element marked `flow:stream="ref"`. Appends by default; pass
|
|
1376
|
+
* `{ replace: true }` to overwrite instead.
|
|
1377
|
+
*
|
|
1378
|
+
* Works only during WebSocket actions (no-op during SSR).
|
|
1379
|
+
*
|
|
1380
|
+
* @security `content` is injected as raw innerHTML on the client. Never pass
|
|
1381
|
+
* unescaped user input directly — sanitize or escape it first, or use a
|
|
1382
|
+
* trusted HTML sanitizer (e.g. DOMPurify) client-side.
|
|
1383
|
+
*
|
|
1384
|
+
* @example
|
|
1385
|
+
* @expose async generate() {
|
|
1386
|
+
* for await (const token of llm.stream(this.prompt)) {
|
|
1387
|
+
* this.stream('answer', token);
|
|
1388
|
+
* }
|
|
1389
|
+
* }
|
|
1390
|
+
*
|
|
1391
|
+
* @param ref the `flow:stream="ref"` target element.
|
|
1392
|
+
* @param content raw HTML appended (or replacing) inside the target.
|
|
1393
|
+
* @param opts pass `{ replace: true }` to overwrite instead of append.
|
|
1394
|
+
* @category Actions
|
|
1395
|
+
*/
|
|
1396
|
+
stream(ref: string, content: string, opts: { replace?: boolean } = {}): void {
|
|
1397
|
+
this._streamSender?.(ref, content, opts.replace ?? false);
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
// ── Built-in actions (invoked by the bridge, not user-callable) ───────────
|
|
1401
|
+
|
|
1402
|
+
/**
|
|
1403
|
+
* @internal Handles flow:model updates from the client.
|
|
1404
|
+
* Silently ignores unexposed or @locked properties — both are defense-in-depth
|
|
1405
|
+
* against confused-deputy attacks (HMAC signing is the primary guard).
|
|
1406
|
+
*/
|
|
1407
|
+
$set(key: string, value: unknown): void {
|
|
1408
|
+
const exposed = getExposedProps(this);
|
|
1409
|
+
const locked = getLockedProps(this);
|
|
1410
|
+
if (!exposed.has(key) && !locked.has(key)) return;
|
|
1411
|
+
if (locked.has(key)) return;
|
|
1412
|
+
// Form object: fill the existing instance instead of replacing it with a plain
|
|
1413
|
+
// object, so its class + methods survive a `value={this.form.field}` edit.
|
|
1414
|
+
const cur = (this as Record<string, unknown>)[key];
|
|
1415
|
+
if (
|
|
1416
|
+
cur &&
|
|
1417
|
+
typeof cur === "object" &&
|
|
1418
|
+
(cur as { __isFlowForm?: unknown }).__isFlowForm === true &&
|
|
1419
|
+
value &&
|
|
1420
|
+
typeof value === "object"
|
|
1421
|
+
) {
|
|
1422
|
+
(cur as { fill(v: Record<string, unknown>): void }).fill(value as Record<string, unknown>);
|
|
1423
|
+
return;
|
|
1424
|
+
}
|
|
1425
|
+
// Signed upload references become TemporaryUploadedFile instances (signature verified);
|
|
1426
|
+
// all other values pass through unchanged.
|
|
1427
|
+
(this as Record<string, unknown>)[key] = resolveUploadValue(value);
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
/** @internal Triggers onMount re-run (used by $flow.refresh()). */
|
|
1431
|
+
$refresh(): void {
|
|
1432
|
+
this._shouldRefresh = true;
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
/**
|
|
1436
|
+
* @internal Apply a client-driven property update, firing the updating/updated
|
|
1437
|
+
* lifecycle hooks (generic + per-property) around the write. Only writable
|
|
1438
|
+
* (`@expose`, non-`@locked`) props fire hooks and get written; everything else
|
|
1439
|
+
* is silently ignored by `$set` (defense-in-depth). An `onUpdating` hook may
|
|
1440
|
+
* throw to reject the update.
|
|
1441
|
+
*/
|
|
1442
|
+
async _applyClientUpdate(key: string, value: unknown, arrayKey?: string): Promise<void> {
|
|
1443
|
+
const exposed = getExposedProps(this);
|
|
1444
|
+
const locked = getLockedProps(this);
|
|
1445
|
+
if (!exposed.has(key) || locked.has(key)) {
|
|
1446
|
+
this.$set(key, value); // no-op for unknown/locked, kept for symmetry
|
|
1447
|
+
return;
|
|
1448
|
+
}
|
|
1449
|
+
await this._emitUpdateHook("onUpdating", key, value, arrayKey);
|
|
1450
|
+
this.$set(key, value);
|
|
1451
|
+
await this._emitUpdateHook("onUpdated", key, value, arrayKey);
|
|
1452
|
+
|
|
1453
|
+
// Real-time validation (Livewire-style): if this field carries a `@validate` rule, validate
|
|
1454
|
+
// just it now and set/clear its error. The changed value reaches here from `flow:model.live` /
|
|
1455
|
+
// `.blur` syncs (and dirty-field batches), so errors appear/clear as the user edits — the
|
|
1456
|
+
// refreshed `_errors` bag is sent to the client in this round-trip's patch. Never throws.
|
|
1457
|
+
await this._validateField(key);
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
/** @internal Fire a generic update hook + its per-property `on…<Prop>` variant. */
|
|
1461
|
+
async _emitUpdateHook(
|
|
1462
|
+
base: "onUpdating" | "onUpdated",
|
|
1463
|
+
prop: string,
|
|
1464
|
+
value: unknown,
|
|
1465
|
+
key?: string,
|
|
1466
|
+
): Promise<void> {
|
|
1467
|
+
await (this[base] as (p: string, v: unknown, k?: string) => Promise<void>)(prop, value, key);
|
|
1468
|
+
const specific = `${base}${prop.charAt(0).toUpperCase()}${prop.slice(1)}`;
|
|
1469
|
+
const fn = (this as Record<string, unknown>)[specific];
|
|
1470
|
+
if (typeof fn === "function") {
|
|
1471
|
+
await (fn as (v: unknown, k?: string) => unknown).call(this, value, key);
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
// ── Effect draining (called by WS handler after action) ──────────────────
|
|
1476
|
+
|
|
1477
|
+
/** @internal — read by the WS handler after action execution. */
|
|
1478
|
+
_drainEffects(): FlowEffects {
|
|
1479
|
+
const effects: FlowEffects = {
|
|
1480
|
+
flashes: this._flashes.splice(0),
|
|
1481
|
+
redirectUrl: this._redirectUrl,
|
|
1482
|
+
shouldRefresh: this._shouldRefresh,
|
|
1483
|
+
scripts: this._clientScripts.splice(0),
|
|
1484
|
+
errors: this._errors,
|
|
1485
|
+
events: this._events.splice(0),
|
|
1486
|
+
downloads: this._downloads.splice(0),
|
|
1487
|
+
title: this._titleValue,
|
|
1488
|
+
};
|
|
1489
|
+
this._redirectUrl = null;
|
|
1490
|
+
this._redirectStatus = null;
|
|
1491
|
+
this._shouldRefresh = false;
|
|
1492
|
+
this._titleValue = null;
|
|
1493
|
+
// _errors intentionally NOT reset here: errors persist across actions so the
|
|
1494
|
+
// client continues showing field messages after e.g. a failed save that then
|
|
1495
|
+
// dispatches a flash. Call resetValidation() or a passing validate() to clear.
|
|
1496
|
+
return effects;
|
|
1497
|
+
}
|
|
1498
|
+
}
|