@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
|
@@ -0,0 +1,978 @@
|
|
|
1
|
+
// ── Decorator metadata registries ─────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// Standard TC39 decorators, keyed by the class prototype; the `get*` readers walk the
|
|
4
|
+
// prototype chain so subclasses inherit.
|
|
5
|
+
//
|
|
6
|
+
// METHOD / GETTER decorators (@computed, @expose on a method, @renderless, @on) register
|
|
7
|
+
// via `addInitializer`, which Bun compiles correctly.
|
|
8
|
+
//
|
|
9
|
+
// FIELD decorators (@expose on a field, @locked, @reactive, @modelable, @transient,
|
|
10
|
+
// @validate, @url, @session) CANNOT defer to a field initializer or to `addInitializer`:
|
|
11
|
+
// Bun 1.3.x cross-wires those across every class defined in one file (a class runs another
|
|
12
|
+
// class's initializers, and a name captured in the closure resolves to the LAST class in
|
|
13
|
+
// the file). The only reliable signal is the decorator BODY, which runs synchronously with
|
|
14
|
+
// the correct `context.name`. So field decorators capture {name, apply} into a module
|
|
15
|
+
// buffer at definition time; the buffer is drained lazily the first time a `get*` reader is
|
|
16
|
+
// called for a class — we construct a probe instance to learn that class's own field names
|
|
17
|
+
// and match the buffered entries by name (see `_drainFields`).
|
|
18
|
+
|
|
19
|
+
import type { RuleBuilder, FieldRule } from "@zerotal/validator";
|
|
20
|
+
import type { LooseEventName } from "./events.ts";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A `@validate` rule: a callback that receives the validator's chain API and returns the built
|
|
24
|
+
* field rule. This is the value passed to the {@link validate} decorator and the shape of each
|
|
25
|
+
* entry in {@link ValidationRules}.
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* ```ts
|
|
29
|
+
* const nameRule: ValidateBuilder = (rule) => rule.required().min(2).max(50);
|
|
30
|
+
* ```
|
|
31
|
+
*
|
|
32
|
+
* @category Validation
|
|
33
|
+
*/
|
|
34
|
+
export type ValidateBuilder = (rule: RuleBuilder) => FieldRule;
|
|
35
|
+
|
|
36
|
+
const _computedProtos = new WeakMap<object, Set<string>>();
|
|
37
|
+
const _transientProtos = new WeakMap<object, Set<string>>();
|
|
38
|
+
const _exposedProps = new WeakMap<object, Set<string>>();
|
|
39
|
+
const _exposedMethods = new WeakMap<object, Set<string>>();
|
|
40
|
+
const _lockedProps = new WeakMap<object, Set<string>>();
|
|
41
|
+
const _renderlessMethods = new WeakMap<object, Set<string>>();
|
|
42
|
+
const _validateRules = new WeakMap<object, Map<string, ValidateBuilder>>();
|
|
43
|
+
const _onListeners = new WeakMap<object, Map<string, string>>();
|
|
44
|
+
const _urlProps = new WeakMap<object, Map<string, UrlOptions>>();
|
|
45
|
+
/**
|
|
46
|
+
* prop -> where its value comes from:
|
|
47
|
+
* undefined — the property's own name is the segment
|
|
48
|
+
* string — that segment
|
|
49
|
+
* class — whichever segment resolved to an instance of it
|
|
50
|
+
*/
|
|
51
|
+
const _routeParamProps = new WeakMap<object, Map<string, ParamSource>>();
|
|
52
|
+
const _sessionProps = new WeakMap<object, Map<string, SessionOptions>>();
|
|
53
|
+
const _reactiveProps = new WeakMap<object, Set<string>>();
|
|
54
|
+
const _modelableProps = new WeakMap<object, Set<string>>();
|
|
55
|
+
const _taskMethods = new WeakMap<object, Set<string>>();
|
|
56
|
+
const _presenceProps = new WeakMap<object, Map<string, PresenceChannel>>();
|
|
57
|
+
const _sharedProps = new WeakMap<object, Map<string, SharedChannel>>();
|
|
58
|
+
|
|
59
|
+
function _register(map: WeakMap<object, Set<string>>, proto: object, key: string): void {
|
|
60
|
+
let s = map.get(proto);
|
|
61
|
+
if (!s) {
|
|
62
|
+
s = new Set();
|
|
63
|
+
map.set(proto, s);
|
|
64
|
+
}
|
|
65
|
+
s.add(key);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function _registerMap<V>(
|
|
69
|
+
map: WeakMap<object, Map<string, V>>,
|
|
70
|
+
proto: object,
|
|
71
|
+
key: string,
|
|
72
|
+
value: V,
|
|
73
|
+
): void {
|
|
74
|
+
let m = map.get(proto);
|
|
75
|
+
if (!m) {
|
|
76
|
+
m = new Map();
|
|
77
|
+
map.set(proto, m);
|
|
78
|
+
}
|
|
79
|
+
m.set(key, value);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function _collect(map: WeakMap<object, Set<string>>, startProto: object): Set<string> {
|
|
83
|
+
const result = new Set<string>();
|
|
84
|
+
let proto: object | null = startProto;
|
|
85
|
+
while (proto && proto !== Object.prototype) {
|
|
86
|
+
const s = map.get(proto);
|
|
87
|
+
if (s) for (const k of s) result.add(k);
|
|
88
|
+
proto = Object.getPrototypeOf(proto) as object | null;
|
|
89
|
+
}
|
|
90
|
+
return result;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function _collectMap<V>(map: WeakMap<object, Map<string, V>>, startProto: object): Map<string, V> {
|
|
94
|
+
const result = new Map<string, V>();
|
|
95
|
+
let proto: object | null = startProto;
|
|
96
|
+
while (proto && proto !== Object.prototype) {
|
|
97
|
+
const m = map.get(proto);
|
|
98
|
+
if (m) for (const [k, v] of m) if (!result.has(k)) result.set(k, v);
|
|
99
|
+
proto = Object.getPrototypeOf(proto) as object | null;
|
|
100
|
+
}
|
|
101
|
+
return result;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Register against the instance's own prototype (from an initializer/addInitializer). */
|
|
105
|
+
function _registerOn(map: WeakMap<object, Set<string>>, self: unknown, key: string): void {
|
|
106
|
+
_register(map, Object.getPrototypeOf(self as object) as object, key);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── Field-decorator buffer (Bun standard-decorator workaround) ────────────────
|
|
110
|
+
|
|
111
|
+
interface PendingField {
|
|
112
|
+
name: string;
|
|
113
|
+
apply: (proto: object) => void;
|
|
114
|
+
}
|
|
115
|
+
const _pendingFields: PendingField[] = [];
|
|
116
|
+
const _drainedProtos = new WeakSet<object>();
|
|
117
|
+
|
|
118
|
+
/** Enqueue a field registration captured (with the correct name) in the decorator body. */
|
|
119
|
+
function _enqueueField(name: string, apply: (proto: object) => void): void {
|
|
120
|
+
_pendingFields.push({ name, apply });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Drain the buffered field registrations belonging to `ctor` onto its prototype. Called
|
|
125
|
+
* lazily by the readers. We construct a throwaway instance to discover the class's own field
|
|
126
|
+
* names IN DECLARATION ORDER (`Object.keys`), then claim the longest contiguous run of buffered
|
|
127
|
+
* entries whose names map to STRICTLY INCREASING positions in that order. A class's field
|
|
128
|
+
* decorators run consecutively at definition time (including a subclass's mixin-supplied fields)
|
|
129
|
+
* and in declaration order, so its block is exactly such a run.
|
|
130
|
+
*
|
|
131
|
+
* The strictly-increasing constraint is what makes this robust when two classes share field
|
|
132
|
+
* names AND their blocks are adjacent in the buffer (e.g. AddTransactionModal and TransferModal
|
|
133
|
+
* both declare `accountId`/`open`/`fAmount`). A plain "longest run ⊆ own" would greedily extend
|
|
134
|
+
* one class's run into the neighbour's leading shared fields; but those shared names map to
|
|
135
|
+
* EARLIER positions in this class's declaration order, so the increasing check stops the run at
|
|
136
|
+
* the true block boundary. Keeps multiple decorators on one field (e.g. `@expose @url page`)
|
|
137
|
+
* together (same name → same index, but they're adjacent duplicates — handled by allowing equal
|
|
138
|
+
* only when it's the identical consecutive name).
|
|
139
|
+
*/
|
|
140
|
+
function _drainFields(ctor: unknown): void {
|
|
141
|
+
if (typeof ctor !== "function") return;
|
|
142
|
+
const proto = (ctor as { prototype?: object }).prototype;
|
|
143
|
+
if (!proto || _drainedProtos.has(proto)) return;
|
|
144
|
+
_drainedProtos.add(proto); // mark first to stay re-entrancy-safe during the probe
|
|
145
|
+
if (_pendingFields.length === 0) return;
|
|
146
|
+
|
|
147
|
+
let ownIndex: Map<string, number>;
|
|
148
|
+
try {
|
|
149
|
+
const keys = Object.keys(new (ctor as new () => object)());
|
|
150
|
+
ownIndex = new Map(keys.map((k, i) => [k, i]));
|
|
151
|
+
} catch {
|
|
152
|
+
return; // not constructible without args — nothing we can match
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Longest contiguous run whose names map to strictly-increasing declaration-order indices
|
|
156
|
+
// (equal allowed only for an identical repeated name — multiple decorators on one field).
|
|
157
|
+
let bestStart = -1;
|
|
158
|
+
let bestLen = 0;
|
|
159
|
+
for (let i = 0; i < _pendingFields.length; i++) {
|
|
160
|
+
if (!ownIndex.has(_pendingFields[i]!.name)) continue;
|
|
161
|
+
let last = -1;
|
|
162
|
+
let lastName = "";
|
|
163
|
+
let j = i;
|
|
164
|
+
while (j < _pendingFields.length) {
|
|
165
|
+
const name = _pendingFields[j]!.name;
|
|
166
|
+
const idx = ownIndex.get(name);
|
|
167
|
+
if (idx === undefined) break;
|
|
168
|
+
if (idx < last || (idx === last && name !== lastName)) break;
|
|
169
|
+
last = idx;
|
|
170
|
+
lastName = name;
|
|
171
|
+
j++;
|
|
172
|
+
}
|
|
173
|
+
if (j - i > bestLen) {
|
|
174
|
+
bestLen = j - i;
|
|
175
|
+
bestStart = i;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (bestStart === -1) return;
|
|
179
|
+
const claimed = _pendingFields.splice(bestStart, bestLen);
|
|
180
|
+
for (const e of claimed) e.apply(proto);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Drain via an instance or a prototype before reading. */
|
|
184
|
+
function _ensureDrained(target: object): void {
|
|
185
|
+
// target may be an instance (use its constructor) or a prototype (use .constructor).
|
|
186
|
+
const ctor = (target as { constructor?: unknown }).constructor;
|
|
187
|
+
if (typeof ctor !== "function") {
|
|
188
|
+
_drainFields(ctor);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
// Drain base-most ancestor → derived, so fields DECLARED on a base class register
|
|
192
|
+
// on the *base* prototype (shared by every subclass) instead of on whichever
|
|
193
|
+
// subclass is constructed first. Without this, sibling subclasses of a decorated
|
|
194
|
+
// base (e.g. one generated page class per admin resource) never inherit the base's
|
|
195
|
+
// @url/@expose/@locked props, so their URL/state binding silently breaks.
|
|
196
|
+
const chain: unknown[] = [];
|
|
197
|
+
for (
|
|
198
|
+
let c: unknown = ctor;
|
|
199
|
+
typeof c === "function" && (c as { prototype?: object }).prototype;
|
|
200
|
+
c = Object.getPrototypeOf(c)
|
|
201
|
+
) {
|
|
202
|
+
chain.unshift(c);
|
|
203
|
+
}
|
|
204
|
+
for (const cls of chain) _drainFields(cls);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Field decorators capture their name+registration in the BODY (correct name) and enqueue
|
|
208
|
+
// it; they do NOT transform the field, so the field's own initializer (default value) runs
|
|
209
|
+
// unchanged. Registration happens later, on first read, via `_drainFields`.
|
|
210
|
+
function _fieldDecorator(
|
|
211
|
+
register: (proto: object, name: string) => void,
|
|
212
|
+
): (value: unknown, context: ClassFieldDecoratorContext) => void {
|
|
213
|
+
return (_value, context) => {
|
|
214
|
+
const name = String(context.name);
|
|
215
|
+
_assertField(name, context);
|
|
216
|
+
_enqueueField(name, (proto) => register(proto, name));
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Reject a field-style decorator applied to a getter/setter/accessor. A `@computed`
|
|
222
|
+
* getter (and any accessor) has no writable snapshot storage, so `@locked`/`@expose`/…
|
|
223
|
+
* on it would serialize a value the framework then tries to write back on update —
|
|
224
|
+
* clobbering the getter. TypeScript already flags this (the decorator's context type
|
|
225
|
+
* excludes getters), but Bun runs code without typechecking, so guard at runtime too.
|
|
226
|
+
*/
|
|
227
|
+
function _assertField(name: string, context: { kind: string }): void {
|
|
228
|
+
if (context.kind !== "field") {
|
|
229
|
+
throw new Error(
|
|
230
|
+
`[Flow] "${name}" — @locked/@url/@session/@transient/@reactive/@modelable can only ` +
|
|
231
|
+
`decorate a field, not a ${context.kind}. A getter isn't stored in the snapshot; ` +
|
|
232
|
+
`use @computed for a derived value (it renders as static server text).`,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function getComputedKeys(instance: object): Set<string> {
|
|
238
|
+
return _collect(_computedProtos, Object.getPrototypeOf(instance) as object);
|
|
239
|
+
}
|
|
240
|
+
export function getTransientKeys(instance: object): Set<string> {
|
|
241
|
+
_ensureDrained(instance);
|
|
242
|
+
return _collect(_transientProtos, Object.getPrototypeOf(instance) as object);
|
|
243
|
+
}
|
|
244
|
+
export function getExposedProps(instance: object): Set<string> {
|
|
245
|
+
_ensureDrained(instance);
|
|
246
|
+
return _collect(_exposedProps, Object.getPrototypeOf(instance) as object);
|
|
247
|
+
}
|
|
248
|
+
export function getLockedProps(instance: object): Set<string> {
|
|
249
|
+
_ensureDrained(instance);
|
|
250
|
+
return _collect(_lockedProps, Object.getPrototypeOf(instance) as object);
|
|
251
|
+
}
|
|
252
|
+
export function getExposedMethods(PageClass: { prototype: object }): Set<string> {
|
|
253
|
+
return _collect(_exposedMethods, PageClass.prototype as object);
|
|
254
|
+
}
|
|
255
|
+
/** `@task` method names — streaming, cancellable long-running actions. */
|
|
256
|
+
export function getTaskMethods(PageClass: { prototype: object }): Set<string> {
|
|
257
|
+
return _collect(_taskMethods, PageClass.prototype as object);
|
|
258
|
+
}
|
|
259
|
+
export function getRenderlessMethods(PageClass: { prototype: object }): Set<string> {
|
|
260
|
+
return _collect(_renderlessMethods, PageClass.prototype as object);
|
|
261
|
+
}
|
|
262
|
+
export function getValidateRules(instance: object): Map<string, ValidateBuilder> {
|
|
263
|
+
_ensureDrained(instance);
|
|
264
|
+
return _collectMap(_validateRules, Object.getPrototypeOf(instance) as object);
|
|
265
|
+
}
|
|
266
|
+
export function getListeners(PageClass: { prototype: object }): Map<string, string> {
|
|
267
|
+
return _collectMap(_onListeners, PageClass.prototype as object);
|
|
268
|
+
}
|
|
269
|
+
export function getUrlProps(instance: object): Map<string, UrlOptions> {
|
|
270
|
+
_ensureDrained(instance);
|
|
271
|
+
return _collectMap(_urlProps, Object.getPrototypeOf(instance) as object);
|
|
272
|
+
}
|
|
273
|
+
export function getSessionProps(instance: object): Map<string, SessionOptions> {
|
|
274
|
+
_ensureDrained(instance);
|
|
275
|
+
return _collectMap(_sessionProps, Object.getPrototypeOf(instance) as object);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* The session key a `@session` field reads and writes.
|
|
280
|
+
*
|
|
281
|
+
* Plain by default — `@session userId` is the session's own `userId`, the same value any
|
|
282
|
+
* controller or other component sees. `scoped: true` namespaces it to the component instead,
|
|
283
|
+
* for state that belongs to this one page and should not collide with anything else.
|
|
284
|
+
*/
|
|
285
|
+
export function sessionKeyFor(prop: string, opts: SessionOptions, componentName: string): string {
|
|
286
|
+
const key = opts.key ?? prop;
|
|
287
|
+
return opts.scoped ? `flow:${componentName}:${key}` : key;
|
|
288
|
+
}
|
|
289
|
+
/** Reactive-prop names. Accepts a prototype directly (e.g. ChildClass.prototype). */
|
|
290
|
+
export function getReactiveProps(proto: object): Set<string> {
|
|
291
|
+
_ensureDrained(proto);
|
|
292
|
+
return _collect(_reactiveProps, proto);
|
|
293
|
+
}
|
|
294
|
+
/** Modelable-prop names. Accepts a prototype directly (e.g. ChildClass.prototype). */
|
|
295
|
+
export function getModelableProps(proto: object): Set<string> {
|
|
296
|
+
_ensureDrained(proto);
|
|
297
|
+
return _collect(_modelableProps, proto);
|
|
298
|
+
}
|
|
299
|
+
/** `@presence` props → their channel resolver, for the instance's class. */
|
|
300
|
+
export function getPresenceProps(instance: object): Map<string, PresenceChannel> {
|
|
301
|
+
_ensureDrained(instance);
|
|
302
|
+
return _collectMap(_presenceProps, Object.getPrototypeOf(instance) as object);
|
|
303
|
+
}
|
|
304
|
+
/** `@shared` props → their channel resolver, for the instance's class. */
|
|
305
|
+
export function getSharedProps(instance: object): Map<string, SharedChannel> {
|
|
306
|
+
_ensureDrained(instance);
|
|
307
|
+
return _collectMap(_sharedProps, Object.getPrototypeOf(instance) as object);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// ── @computed ─────────────────────────────────────────────────────────────────
|
|
311
|
+
|
|
312
|
+
/** Wrap a getter so it memoizes its result while a render pass is active. */
|
|
313
|
+
function _memoizeGetter(orig: () => unknown, name: string): () => unknown {
|
|
314
|
+
return function (this: Record<string, unknown>): unknown {
|
|
315
|
+
if (!this["__flowComputing"]) return orig.call(this); // only cache during render
|
|
316
|
+
let cache = this["__flowComputed"] as Map<string, unknown> | undefined;
|
|
317
|
+
if (!cache) {
|
|
318
|
+
cache = new Map();
|
|
319
|
+
this["__flowComputed"] = cache;
|
|
320
|
+
}
|
|
321
|
+
if (cache.has(name)) return cache.get(name);
|
|
322
|
+
const v = orig.call(this);
|
|
323
|
+
cache.set(name, v);
|
|
324
|
+
return v;
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Marks a getter as computed — a value derived from other state, excluded from the snapshot.
|
|
330
|
+
*
|
|
331
|
+
* @remarks
|
|
332
|
+
* A computed getter is not serialized and is not client-writable; it renders as static
|
|
333
|
+
* server-produced text. The result is memoized for the duration of a single render pass
|
|
334
|
+
* (Livewire-style per-request caching): an expensive getter read several times in one template
|
|
335
|
+
* runs once. The cache is scoped to the active render (see `_renderFlowPage`), so reads
|
|
336
|
+
* outside rendering always recompute and never go stale. Apply only to a getter — a field-style
|
|
337
|
+
* decorator on it is rejected; conversely `@computed` on a non-getter is a TypeScript error.
|
|
338
|
+
*
|
|
339
|
+
* @example
|
|
340
|
+
* ```tsx
|
|
341
|
+
* class Cart extends Component {
|
|
342
|
+
* @expose items: LineItem[] = [];
|
|
343
|
+
*
|
|
344
|
+
* @computed get total(): number {
|
|
345
|
+
* return this.items.reduce((sum, i) => sum + i.price * i.qty, 0);
|
|
346
|
+
* }
|
|
347
|
+
* }
|
|
348
|
+
* ```
|
|
349
|
+
*
|
|
350
|
+
* @category Reactivity
|
|
351
|
+
*/
|
|
352
|
+
export function computed<This, Return>(
|
|
353
|
+
getter: (this: This) => Return,
|
|
354
|
+
context: ClassGetterDecoratorContext<This, Return>,
|
|
355
|
+
): (this: This) => Return {
|
|
356
|
+
const name = String(context.name);
|
|
357
|
+
|
|
358
|
+
context.addInitializer(function (this: This) {
|
|
359
|
+
_registerOn(_computedProtos, this, name);
|
|
360
|
+
});
|
|
361
|
+
return _memoizeGetter(getter, name) as (this: This) => Return;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// ── @transient ────────────────────────────────────────────────────────────────
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Marks a field as transient — excluded from the snapshot entirely.
|
|
368
|
+
*
|
|
369
|
+
* @remarks
|
|
370
|
+
* Unlike `@computed` (which is derived and re-runs each render), a transient field is ordinary
|
|
371
|
+
* server-side state that simply never crosses the wire and is not persisted between requests:
|
|
372
|
+
* it holds its default value at the start of each render and is invisible to the client. Use it
|
|
373
|
+
* for server-only scratch state (caches, handles, injected services) that must not be serialized.
|
|
374
|
+
* Applies only to a field; a getter/accessor is rejected at runtime.
|
|
375
|
+
*
|
|
376
|
+
* @example
|
|
377
|
+
* ```tsx
|
|
378
|
+
* class Report extends Component {
|
|
379
|
+
* // Server-only handle — never serialized to the client.
|
|
380
|
+
* @transient private db!: DatabaseConnection;
|
|
381
|
+
* }
|
|
382
|
+
* ```
|
|
383
|
+
*
|
|
384
|
+
* @category Exposure & state
|
|
385
|
+
*/
|
|
386
|
+
export const transient = _fieldDecorator((proto, name) => _register(_transientProtos, proto, name));
|
|
387
|
+
|
|
388
|
+
// ── @expose ───────────────────────────────────────────────────────────────────
|
|
389
|
+
|
|
390
|
+
type ExposeContext = ClassFieldDecoratorContext | ClassMethodDecoratorContext;
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* The opt-in network boundary: makes a member reachable from the client and crosses the wire.
|
|
394
|
+
*
|
|
395
|
+
* @remarks
|
|
396
|
+
* On a **field**, the property is included in the snapshot and is mutable by the client (e.g. via
|
|
397
|
+
* `flow:model`), so it round-trips both directions. On a **method**, the method becomes callable
|
|
398
|
+
* from the browser over the WebSocket as an action. Nothing not marked (directly or via a
|
|
399
|
+
* decorator that implies it, such as `@locked`, `@task`, `@on`, `@reactive`, `@modelable`,
|
|
400
|
+
* `@presence`, `@shared`) is exposed. Rejected on a getter/accessor — use `@computed` for a
|
|
401
|
+
* derived value, or an `@expose` field for client-writable state.
|
|
402
|
+
*
|
|
403
|
+
* @example
|
|
404
|
+
* ```tsx
|
|
405
|
+
* class Counter extends Component {
|
|
406
|
+
* @expose count = 0;
|
|
407
|
+
*
|
|
408
|
+
* @expose increment() {
|
|
409
|
+
* this.count++;
|
|
410
|
+
* }
|
|
411
|
+
* }
|
|
412
|
+
* ```
|
|
413
|
+
*
|
|
414
|
+
* @category Exposure & state
|
|
415
|
+
*/
|
|
416
|
+
export function expose(_value: unknown, context: ExposeContext): void {
|
|
417
|
+
const name = String(context.name);
|
|
418
|
+
const kind = (context as { kind: string }).kind;
|
|
419
|
+
if (kind !== "field" && kind !== "method") {
|
|
420
|
+
throw new Error(
|
|
421
|
+
`[Flow] @expose can only decorate a field or a method, not a ${kind} ("${name}"). ` +
|
|
422
|
+
`A getter isn't stored in the snapshot; use @computed for a derived value ` +
|
|
423
|
+
`(it renders as static server text), or an @expose field for client-writable state.`,
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
if (context.kind === "field") {
|
|
427
|
+
// Field: capture in the body and enqueue (see _fieldDecorator / _drainFields).
|
|
428
|
+
_enqueueField(name, (proto) => _register(_exposedProps, proto, name));
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
// Method: addInitializer is available (and reliable) on method contexts.
|
|
432
|
+
context.addInitializer(function (this: unknown) {
|
|
433
|
+
_registerOn(_exposedMethods, this, name);
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// ── @locked ───────────────────────────────────────────────────────────────────
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Marks a field as exposed but read-only client-side: it is included in the snapshot (the client
|
|
441
|
+
* can read/render it) but the client cannot mutate it via `flow:model`.
|
|
442
|
+
*
|
|
443
|
+
* @remarks
|
|
444
|
+
* `@locked` implies `@expose` (it registers the prop as exposed) while additionally flagging it
|
|
445
|
+
* as locked, so client-originated writes are rejected. Use it for server-authoritative state the
|
|
446
|
+
* UI displays but must not change directly — the value only moves via server actions. Applies to
|
|
447
|
+
* a field only.
|
|
448
|
+
*
|
|
449
|
+
* @example
|
|
450
|
+
* ```tsx
|
|
451
|
+
* class Profile extends Component {
|
|
452
|
+
* // Client renders it, but cannot bind it with flow:model.
|
|
453
|
+
* @locked role = "member";
|
|
454
|
+
* }
|
|
455
|
+
* ```
|
|
456
|
+
*
|
|
457
|
+
* @category Exposure & state
|
|
458
|
+
*/
|
|
459
|
+
export const locked = _fieldDecorator((proto, name) => {
|
|
460
|
+
_register(_exposedProps, proto, name);
|
|
461
|
+
_register(_lockedProps, proto, name);
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
// ── @presence ─────────────────────────────────────────────────────────────────
|
|
465
|
+
// Binds a property to a broadcast presence channel: the framework keeps it filled
|
|
466
|
+
// with the live member list (who's here), updating on join/leave. The channel is
|
|
467
|
+
// resolved per-instance — a static string, or a `(self) => string` for dynamic rooms.
|
|
468
|
+
// The prop is server-controlled (locked): in the snapshot, not client-writable.
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* A presence channel identifier: either a static channel name, or a resolver called with the
|
|
472
|
+
* component instance to compute a per-instance name (for dynamic rooms).
|
|
473
|
+
*
|
|
474
|
+
* @category Realtime
|
|
475
|
+
*/
|
|
476
|
+
export type PresenceChannel = string | ((self: Record<string, any>) => string);
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Binds a field to a broadcast presence channel — the framework keeps it filled with the live
|
|
480
|
+
* member list (who's here), updating on join/leave.
|
|
481
|
+
*
|
|
482
|
+
* @remarks
|
|
483
|
+
* The channel is resolved per-instance: pass a static string, or a `(self) => string` resolver
|
|
484
|
+
* for dynamic rooms. The bound prop is server-controlled — like `@locked`, it is included in the
|
|
485
|
+
* snapshot but is not client-writable. Applies to a field only.
|
|
486
|
+
*
|
|
487
|
+
* @param channel - The channel name, or a resolver over the component instance.
|
|
488
|
+
* @returns A field decorator that registers the presence binding.
|
|
489
|
+
*
|
|
490
|
+
* @example
|
|
491
|
+
* ```tsx
|
|
492
|
+
* class RoomHeader extends Component {
|
|
493
|
+
* @presence((self) => `room.${self.roomId}`)
|
|
494
|
+
* members: PresenceMember[] = [];
|
|
495
|
+
*
|
|
496
|
+
* @locked roomId = "";
|
|
497
|
+
* }
|
|
498
|
+
* ```
|
|
499
|
+
*
|
|
500
|
+
* @category Realtime
|
|
501
|
+
*/
|
|
502
|
+
export function presence(
|
|
503
|
+
channel: PresenceChannel,
|
|
504
|
+
): (value: unknown, context: ClassFieldDecoratorContext) => void {
|
|
505
|
+
return _fieldDecorator((proto, name) => {
|
|
506
|
+
_registerMap(_presenceProps, proto, name, channel);
|
|
507
|
+
// Serialized + read-only client-side, like @locked.
|
|
508
|
+
_register(_exposedProps, proto, name);
|
|
509
|
+
_register(_lockedProps, proto, name);
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** Resolve a `@presence` channel for a component instance (adds no prefix). */
|
|
514
|
+
export function resolvePresenceChannel(channel: PresenceChannel, instance: object): string {
|
|
515
|
+
return typeof channel === "function" ? channel(instance as Record<string, unknown>) : channel;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// ── @shared ─────────────────────────────────────────────────────────────────
|
|
519
|
+
// Binds a property to convergent, server-authoritative shared state on a broadcast
|
|
520
|
+
// channel: mutating it in an action writes to a per-channel room store and broadcasts
|
|
521
|
+
// to the channel, so every other subscriber's component re-reads and converges. The
|
|
522
|
+
// channel resolves per-instance (a static string, or `(self) => string` for dynamic
|
|
523
|
+
// rooms), the same shape as @presence — so one "room" channel can carry both who's-here
|
|
524
|
+
// (@presence) and shared state (@shared). Serialized + read-only client-side (@locked):
|
|
525
|
+
// clients render it but can only change it through @expose actions (last-write-wins).
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* A shared-state channel identifier: either a static channel name, or a resolver called with the
|
|
529
|
+
* component instance to compute a per-instance name (for dynamic rooms).
|
|
530
|
+
*
|
|
531
|
+
* @category Realtime
|
|
532
|
+
*/
|
|
533
|
+
export type SharedChannel = string | ((self: Record<string, any>) => string);
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Binds a field to convergent, server-authoritative shared state on a broadcast channel.
|
|
537
|
+
*
|
|
538
|
+
* @remarks
|
|
539
|
+
* Mutating the field inside an action writes to a per-channel room store and broadcasts to the
|
|
540
|
+
* channel, so every other subscriber's component re-reads and converges (last-write-wins). The
|
|
541
|
+
* channel resolves per-instance (a static string, or `(self) => string` for dynamic rooms) — the
|
|
542
|
+
* same shape as {@link presence}, so one "room" channel can carry both who's-here (`@presence`)
|
|
543
|
+
* and shared state (`@shared`). Serialized and read-only client-side (like `@locked`): clients
|
|
544
|
+
* render it but can only change it through `@expose` actions. Applies to a field only.
|
|
545
|
+
*
|
|
546
|
+
* @param channel - The channel name, or a resolver over the component instance.
|
|
547
|
+
* @returns A field decorator that registers the shared-state binding.
|
|
548
|
+
*
|
|
549
|
+
* @example
|
|
550
|
+
* ```tsx
|
|
551
|
+
* class Whiteboard extends Component {
|
|
552
|
+
* @shared((self) => `board.${self.boardId}`)
|
|
553
|
+
* strokes: Stroke[] = [];
|
|
554
|
+
*
|
|
555
|
+
* @locked boardId = "";
|
|
556
|
+
*
|
|
557
|
+
* @expose addStroke(stroke: Stroke) {
|
|
558
|
+
* this.strokes = [...this.strokes, stroke]; // broadcasts to every subscriber
|
|
559
|
+
* }
|
|
560
|
+
* }
|
|
561
|
+
* ```
|
|
562
|
+
*
|
|
563
|
+
* @category Realtime
|
|
564
|
+
*/
|
|
565
|
+
export function shared(
|
|
566
|
+
channel: SharedChannel,
|
|
567
|
+
): (value: unknown, context: ClassFieldDecoratorContext) => void {
|
|
568
|
+
return _fieldDecorator((proto, name) => {
|
|
569
|
+
_registerMap(_sharedProps, proto, name, channel);
|
|
570
|
+
// Serialized + read-only client-side, like @locked — writes go through actions.
|
|
571
|
+
_register(_exposedProps, proto, name);
|
|
572
|
+
_register(_lockedProps, proto, name);
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/** Resolve a `@shared` channel for a component instance (adds no prefix). */
|
|
577
|
+
export function resolveSharedChannel(channel: SharedChannel, instance: object): string {
|
|
578
|
+
return typeof channel === "function" ? channel(instance as Record<string, unknown>) : channel;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// ── @reactive ───────────────────────────────────────────────────────────────
|
|
582
|
+
// A child-component prop the PARENT can push new values into on every re-render.
|
|
583
|
+
// Included in the snapshot and client-writable (so the framework can deliver
|
|
584
|
+
// parent updates), and re-emitted by the parent so the child re-renders when the
|
|
585
|
+
// bound value changes. Mirrors Livewire's #[Reactive].
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Marks a child-component prop as reactive — the parent re-pushes new values into it on every
|
|
589
|
+
* re-render.
|
|
590
|
+
*
|
|
591
|
+
* @remarks
|
|
592
|
+
* The prop is included in the snapshot and is client-writable (so the framework can deliver the
|
|
593
|
+
* parent's updates), and the parent re-emits it so the child re-renders when the bound value
|
|
594
|
+
* changes. This is a one-way parent→child binding; use `@modelable` for two-way. Mirrors
|
|
595
|
+
* Livewire's `#[Reactive]`. Applies to a field only.
|
|
596
|
+
*
|
|
597
|
+
* @example
|
|
598
|
+
* ```tsx
|
|
599
|
+
* class PriceTag extends Component {
|
|
600
|
+
* // Re-pushed by the parent whenever the parent's bound value changes.
|
|
601
|
+
* @reactive amount = 0;
|
|
602
|
+
* }
|
|
603
|
+
* ```
|
|
604
|
+
*
|
|
605
|
+
* @category Reactivity
|
|
606
|
+
*/
|
|
607
|
+
export const reactive = _fieldDecorator((proto, name) => {
|
|
608
|
+
_register(_exposedProps, proto, name);
|
|
609
|
+
_register(_reactiveProps, proto, name);
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
// ── @modelable ────────────────────────────────────────────────────────────────
|
|
613
|
+
// A reactive prop that ALSO syncs back to the parent: `value={this.x}` on a child
|
|
614
|
+
// component binds the parent's `x` to the child's modelable prop both ways. Mirrors
|
|
615
|
+
// Livewire's #[Modelable]. A modelable prop is implicitly reactive.
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Marks a child-component prop as two-way bound to the parent (parent↔child).
|
|
619
|
+
*
|
|
620
|
+
* @remarks
|
|
621
|
+
* A modelable prop is reactive (the parent pushes new values in on re-render) AND syncs back:
|
|
622
|
+
* `value={this.x}` on a child component binds the parent's `x` to the child's modelable prop in
|
|
623
|
+
* both directions. A modelable prop is implicitly reactive (it registers as both). Mirrors
|
|
624
|
+
* Livewire's `#[Modelable]`. Applies to a field only.
|
|
625
|
+
*
|
|
626
|
+
* @example
|
|
627
|
+
* ```tsx
|
|
628
|
+
* class TextField extends Component {
|
|
629
|
+
* // Parent binds with `value={this.query}`; edits here flow back to the parent.
|
|
630
|
+
* @modelable value = "";
|
|
631
|
+
* }
|
|
632
|
+
* ```
|
|
633
|
+
*
|
|
634
|
+
* @category Reactivity
|
|
635
|
+
*/
|
|
636
|
+
export const modelable = _fieldDecorator((proto, name) => {
|
|
637
|
+
_register(_exposedProps, proto, name);
|
|
638
|
+
_register(_reactiveProps, proto, name);
|
|
639
|
+
_register(_modelableProps, proto, name);
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
// ── @renderless ───────────────────────────────────────────────────────────────
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* Marks an action method as renderless — the action runs on the server but the follow-up
|
|
646
|
+
* re-render is skipped.
|
|
647
|
+
*
|
|
648
|
+
* @remarks
|
|
649
|
+
* Unlike `@task`, `@renderless` does NOT imply `@expose` — it only registers the method as
|
|
650
|
+
* renderless, so it must be combined with `@expose` (or another decorator that exposes it) to be
|
|
651
|
+
* callable from the client. Use it for side-effect-only actions whose result the UI does not need
|
|
652
|
+
* to reflect (logging, fire-and-forget writes), avoiding the cost of a re-render and patch.
|
|
653
|
+
* Applies to a method only.
|
|
654
|
+
*
|
|
655
|
+
* @example
|
|
656
|
+
* ```tsx
|
|
657
|
+
* class Analytics extends Component {
|
|
658
|
+
* @expose @renderless track(event: string) {
|
|
659
|
+
* this.logger.record(event); // no DOM update needed
|
|
660
|
+
* }
|
|
661
|
+
* }
|
|
662
|
+
* ```
|
|
663
|
+
*
|
|
664
|
+
* @category Actions
|
|
665
|
+
*/
|
|
666
|
+
export function renderless(_fn: unknown, context: ClassMethodDecoratorContext): void {
|
|
667
|
+
context.addInitializer(function (this: unknown) {
|
|
668
|
+
_registerOn(_renderlessMethods, this, String(context.name));
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// ── @task ───────────────────────────────────────────────────────────────────────
|
|
673
|
+
// A long-running, cancellable action whose incremental field writes stream to the client.
|
|
674
|
+
// While a @task runs, the framework flushes throttled partial patches — so `this.answer +=
|
|
675
|
+
// token` appears live — keeps the triggering control in its loading state for the whole
|
|
676
|
+
// duration, and exposes `this.signal` (an AbortSignal) that `this.cancel()` on the client
|
|
677
|
+
// trips for cooperative cancellation. Auto-@expose (callable from the browser).
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* Marks an async method as a streaming, cancellable task. Implicitly `@expose` (callable from the
|
|
681
|
+
* browser).
|
|
682
|
+
*
|
|
683
|
+
* @remarks
|
|
684
|
+
* While a `@task` runs, the framework flushes throttled partial patches — so incremental field
|
|
685
|
+
* writes such as `this.answer += token` appear live in the client — keeps the triggering control
|
|
686
|
+
* in its loading state for the whole duration, and exposes `this.signal` (an `AbortSignal`) that
|
|
687
|
+
* `this.cancel()` on the client trips for cooperative cancellation. Because it auto-exposes, no
|
|
688
|
+
* separate `@expose` is needed. Applies to a (typically async) method.
|
|
689
|
+
*
|
|
690
|
+
* @example
|
|
691
|
+
* ```tsx
|
|
692
|
+
* class Chat extends Component {
|
|
693
|
+
* @expose answer = "";
|
|
694
|
+
*
|
|
695
|
+
* @task async ask(prompt: string) {
|
|
696
|
+
* for await (const token of streamCompletion(prompt, this.signal)) {
|
|
697
|
+
* this.answer += token; // each write streams to the client
|
|
698
|
+
* }
|
|
699
|
+
* }
|
|
700
|
+
* }
|
|
701
|
+
* ```
|
|
702
|
+
*
|
|
703
|
+
* @category Actions
|
|
704
|
+
*/
|
|
705
|
+
export function task(_fn: unknown, context: ClassMethodDecoratorContext): void {
|
|
706
|
+
context.addInitializer(function (this: unknown) {
|
|
707
|
+
const proto = Object.getPrototypeOf(this as object) as object;
|
|
708
|
+
const name = String(context.name);
|
|
709
|
+
_register(_exposedMethods, proto, name);
|
|
710
|
+
_register(_taskMethods, proto, name);
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// ── @validate ─────────────────────────────────────────────────────────────────
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* Attaches a validation rule to a field using the framework validator's fluent chain.
|
|
718
|
+
*
|
|
719
|
+
* @remarks
|
|
720
|
+
* The rule is read by `this.validate()` (called with no argument) and by real-time validation on
|
|
721
|
+
* client update. `@validate` only registers the rule — it does not expose the field, so pair it
|
|
722
|
+
* with `@expose` for client-editable inputs. Applies to a field only. The decorator-attached
|
|
723
|
+
* rules mirror the object form accepted by `this.validate({ ... })` (see {@link ValidationRules}).
|
|
724
|
+
*
|
|
725
|
+
* @param build - A {@link ValidateBuilder} that builds the field rule from the chain API.
|
|
726
|
+
* @returns A field decorator that registers the rule.
|
|
727
|
+
*
|
|
728
|
+
* @example
|
|
729
|
+
* ```tsx
|
|
730
|
+
* class SignupForm extends Component {
|
|
731
|
+
* @expose @validate((rule) => rule.required().min(2).max(50)) name = "";
|
|
732
|
+
* @expose @validate((rule) => rule.required().email()) email = "";
|
|
733
|
+
* @expose @validate((rule) => rule.required().min(8).confirmed()) password = "";
|
|
734
|
+
* }
|
|
735
|
+
* ```
|
|
736
|
+
*
|
|
737
|
+
* @category Validation
|
|
738
|
+
*/
|
|
739
|
+
export function validate(
|
|
740
|
+
build: ValidateBuilder,
|
|
741
|
+
): (value: unknown, context: ClassFieldDecoratorContext) => void {
|
|
742
|
+
return _fieldDecorator((proto, name) => _registerMap(_validateRules, proto, name, build));
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// ── @on ───────────────────────────────────────────────────────────────────────
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* Binds a method as a cross-component (realtime) event listener. Auto-exposes the method.
|
|
749
|
+
*
|
|
750
|
+
* @remarks
|
|
751
|
+
* When the named event is dispatched (via `dispatch` / `dispatchTo` / `dispatchSelf`, or an
|
|
752
|
+
* `echo:…` broadcast), the decorated method is invoked with the event payload. The event name
|
|
753
|
+
* autocompletes to the known {@link FlowEvents} contract while still accepting any string (for
|
|
754
|
+
* `echo:…` broadcasts and gradually-typed events). Annotate the handler's parameter with
|
|
755
|
+
* `EventPayload<"name">` to type the payload against the same contract. Because it auto-exposes,
|
|
756
|
+
* no separate `@expose` is needed. Applies to a method.
|
|
757
|
+
*
|
|
758
|
+
* @param eventName - The event to listen for; a known {@link FlowEvents} key or any string.
|
|
759
|
+
* @returns A method decorator that registers the listener.
|
|
760
|
+
*
|
|
761
|
+
* @example
|
|
762
|
+
* ```tsx
|
|
763
|
+
* class Feed extends Component {
|
|
764
|
+
* @expose posts: Post[] = [];
|
|
765
|
+
*
|
|
766
|
+
* @on("post-created")
|
|
767
|
+
* onPostCreated(payload: EventPayload<"post-created">) {
|
|
768
|
+
* this.posts = [payload, ...this.posts];
|
|
769
|
+
* }
|
|
770
|
+
* }
|
|
771
|
+
* ```
|
|
772
|
+
*
|
|
773
|
+
* @category Realtime
|
|
774
|
+
*/
|
|
775
|
+
export function on(
|
|
776
|
+
eventName: LooseEventName,
|
|
777
|
+
): (fn: unknown, context: ClassMethodDecoratorContext) => void {
|
|
778
|
+
return (_fn: unknown, context: ClassMethodDecoratorContext): void => {
|
|
779
|
+
context.addInitializer(function (this: unknown) {
|
|
780
|
+
const proto = Object.getPrototypeOf(this as object) as object;
|
|
781
|
+
const name = String(context.name);
|
|
782
|
+
_register(_exposedMethods, proto, name);
|
|
783
|
+
_registerMap(_onListeners, proto, eventName, name);
|
|
784
|
+
});
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// ── @url ──────────────────────────────────────────────────────────────────────
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* Options for the {@link url} decorator, controlling how a property maps onto the browser URL
|
|
792
|
+
* query string.
|
|
793
|
+
*
|
|
794
|
+
* @category Persistence
|
|
795
|
+
*/
|
|
796
|
+
export interface UrlOptions {
|
|
797
|
+
/** Override the URL query parameter name. Defaults to the property name. */
|
|
798
|
+
as?: string;
|
|
799
|
+
/** Push a new history entry or replace the current one. Defaults to `'replace'`. */
|
|
800
|
+
history?: "push" | "replace";
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
type UrlDecorator = (value: unknown, context: ClassFieldDecoratorContext) => void;
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* Syncs a field to the browser URL query string, so its value is reflected in and restored from
|
|
807
|
+
* the URL.
|
|
808
|
+
*
|
|
809
|
+
* @remarks
|
|
810
|
+
* Usable bare as `@url` (no parens) or configured as `@url({ as: 'page', history: 'push' })` — see
|
|
811
|
+
* {@link UrlOptions}. By default the query parameter takes the property's name and updates
|
|
812
|
+
* `replace` the current history entry.
|
|
813
|
+
*
|
|
814
|
+
* A URL-synced field is client-visible by definition, so `@url` **implies `@expose`** — no need
|
|
815
|
+
* to write both. Add `@locked` for a value the client reads but the server owns. Applies to a
|
|
816
|
+
* field.
|
|
817
|
+
*
|
|
818
|
+
* @param opts - Optional {@link UrlOptions} when called with parentheses.
|
|
819
|
+
* @returns When configured, a field decorator; when used bare, nothing (applied directly).
|
|
820
|
+
*
|
|
821
|
+
* @example
|
|
822
|
+
* ```tsx
|
|
823
|
+
* class ProductList extends Component {
|
|
824
|
+
* @url page = 1;
|
|
825
|
+
* @url({ as: "q", history: "push" }) search = "";
|
|
826
|
+
* }
|
|
827
|
+
* ```
|
|
828
|
+
*
|
|
829
|
+
* @category Persistence
|
|
830
|
+
*/
|
|
831
|
+
export function url(value: unknown, context: ClassFieldDecoratorContext): void;
|
|
832
|
+
export function url(opts?: UrlOptions): UrlDecorator;
|
|
833
|
+
export function url(
|
|
834
|
+
optsOrValue?: UrlOptions | unknown,
|
|
835
|
+
context?: ClassFieldDecoratorContext,
|
|
836
|
+
): UrlDecorator | void {
|
|
837
|
+
if (context !== undefined) {
|
|
838
|
+
return _urlDecorator({})(optsOrValue, context); // bare @url
|
|
839
|
+
}
|
|
840
|
+
const opts = (optsOrValue ?? {}) as UrlOptions;
|
|
841
|
+
return _urlDecorator(opts);
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
function _urlDecorator(opts: UrlOptions): UrlDecorator {
|
|
845
|
+
return _fieldDecorator((proto, name) => {
|
|
846
|
+
_registerMap(_urlProps, proto, name, opts);
|
|
847
|
+
// A URL-synced field is client-visible by definition — its value is in the address bar,
|
|
848
|
+
// and keeping it in sync means the client holds it. So @url implies @expose; add @locked
|
|
849
|
+
// alongside it for a value the client reads but may not write.
|
|
850
|
+
_register(_exposedProps, proto, name);
|
|
851
|
+
});
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// ── @param ────────────────────────────────────────────────────────────────────
|
|
855
|
+
|
|
856
|
+
type ParamDecorator = (value: unknown, context: ClassFieldDecoratorContext) => void;
|
|
857
|
+
|
|
858
|
+
/** A model class used as a `@param` token — matched against the resolved segment values. */
|
|
859
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- any model constructor shape
|
|
860
|
+
export type ParamModel = abstract new (...args: any[]) => unknown;
|
|
861
|
+
|
|
862
|
+
/** Where a `@param` field reads from: a segment name, a model class, or its own name. */
|
|
863
|
+
export type ParamSource = string | ParamModel | undefined;
|
|
864
|
+
|
|
865
|
+
/** Fields bound to a route segment, with the source each one reads. */
|
|
866
|
+
export function getRouteParamProps(instance: object): Map<string, ParamSource> {
|
|
867
|
+
_ensureDrained(instance);
|
|
868
|
+
return _collectMap(_routeParamProps, Object.getPrototypeOf(instance) as object);
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/**
|
|
872
|
+
* Fills a field from the matched route segment before `onMount()` runs — including a
|
|
873
|
+
* [route-model binding](/docs/routing#route-model-binding), so a page on `/posts/:post`
|
|
874
|
+
* receives the loaded `Post` without querying for it.
|
|
875
|
+
*
|
|
876
|
+
* @remarks
|
|
877
|
+
* Usable bare as `@param` (the field's own name is the segment), as `@param('slug')` to read a
|
|
878
|
+
* differently-named segment, or as `@param(Post)` to take whichever segment resolved to an
|
|
879
|
+
* instance of that model — so the field never has to track what a route called it. One model
|
|
880
|
+
* claims one segment name, so the token form is unambiguous for anything binding implicitly;
|
|
881
|
+
* a route that hand-binds a second segment to the same model yields the leftmost.
|
|
882
|
+
*
|
|
883
|
+
* Seeding happens **once, on the initial GET** — the only request that carries the URL's
|
|
884
|
+
* segments. On subsequent WebSocket actions the value is restored from the snapshot instead
|
|
885
|
+
* (a model through Flow's model synth), so pair it with `@locked` and the record is never
|
|
886
|
+
* re-queried.
|
|
887
|
+
*
|
|
888
|
+
* `@param` registers the binding only; combine it with `@locked` for server-owned data, or
|
|
889
|
+
* `@expose` if the client may also write it. Applies to a field.
|
|
890
|
+
*
|
|
891
|
+
* @param name - The route segment to read; defaults to the property's own name.
|
|
892
|
+
* @returns When configured, a field decorator; when used bare, nothing (applied directly).
|
|
893
|
+
*
|
|
894
|
+
* @example
|
|
895
|
+
* ```tsx
|
|
896
|
+
* class PostDetailPage extends Component {
|
|
897
|
+
* @locked @param post!: Post; // /posts/:post -> the bound Post
|
|
898
|
+
* @locked @param(Post) article!: Post; // whichever segment resolved to a Post
|
|
899
|
+
* @locked @param("tab") activeTab = ""; // /posts/:post/:tab -> the raw segment
|
|
900
|
+
* }
|
|
901
|
+
* ```
|
|
902
|
+
*
|
|
903
|
+
* @category Persistence
|
|
904
|
+
*/
|
|
905
|
+
export function param(value: unknown, context: ClassFieldDecoratorContext): void;
|
|
906
|
+
export function param(source?: string | ParamModel): ParamDecorator;
|
|
907
|
+
export function param(
|
|
908
|
+
sourceOrValue?: string | ParamModel | unknown,
|
|
909
|
+
context?: ClassFieldDecoratorContext,
|
|
910
|
+
): ParamDecorator | void {
|
|
911
|
+
if (context !== undefined) {
|
|
912
|
+
return _paramDecorator(undefined)(sourceOrValue, context); // bare @param
|
|
913
|
+
}
|
|
914
|
+
return _paramDecorator(sourceOrValue as ParamSource);
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
function _paramDecorator(source: ParamSource): ParamDecorator {
|
|
918
|
+
return _fieldDecorator((proto, prop) => _registerMap(_routeParamProps, proto, prop, source));
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
// ── @session ──────────────────────────────────────────────────────────────────
|
|
922
|
+
|
|
923
|
+
/** Options for {@link session}. */
|
|
924
|
+
export interface SessionOptions {
|
|
925
|
+
/** Session key to read and write. Defaults to the property's own name. */
|
|
926
|
+
key?: string;
|
|
927
|
+
/**
|
|
928
|
+
* Namespace the key to this component (`flow:<Component>:<key>`), so the value belongs to
|
|
929
|
+
* this page alone and cannot collide with another component's field of the same name.
|
|
930
|
+
* Default: `false` — the key is the session's own.
|
|
931
|
+
*/
|
|
932
|
+
scoped?: boolean;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
type SessionDecorator = (value: unknown, context: ClassFieldDecoratorContext) => void;
|
|
936
|
+
|
|
937
|
+
/**
|
|
938
|
+
* Binds a field to the server-side session, so its value survives across page loads.
|
|
939
|
+
*
|
|
940
|
+
* @remarks
|
|
941
|
+
* Requires `SessionMiddleware`. The field reads and writes the session's own key of the same
|
|
942
|
+
* name, so `@session userId` is the same `userId` a controller sees — pass `{ key }` to read a
|
|
943
|
+
* differently-named one, or `{ scoped: true }` to namespace it to this component instead.
|
|
944
|
+
*
|
|
945
|
+
* The value stays **server-side**: `@session` alone puts nothing in the snapshot, so the
|
|
946
|
+
* browser never sees it and cannot write it. Add `@locked` for a value the client should read.
|
|
947
|
+
* Applies to a field only.
|
|
948
|
+
*
|
|
949
|
+
* @param opts - Optional {@link SessionOptions} when called with parentheses.
|
|
950
|
+
* @returns When configured, a field decorator; when used bare, nothing (applied directly).
|
|
951
|
+
*
|
|
952
|
+
* @example
|
|
953
|
+
* ```tsx
|
|
954
|
+
* class Preferences extends Component {
|
|
955
|
+
* @session userId = ""; // the session's `userId`
|
|
956
|
+
* @session({ key: "s" }) whatever = 0; // the session's `s`
|
|
957
|
+
* @session({ scoped: true }) draft = ""; // flow:Preferences:draft
|
|
958
|
+
* @locked @session theme = "light"; // readable by the client, server-owned
|
|
959
|
+
* }
|
|
960
|
+
* ```
|
|
961
|
+
*
|
|
962
|
+
* @category Persistence
|
|
963
|
+
*/
|
|
964
|
+
export function session(value: unknown, context: ClassFieldDecoratorContext): void;
|
|
965
|
+
export function session(opts?: SessionOptions): SessionDecorator;
|
|
966
|
+
export function session(
|
|
967
|
+
optsOrValue?: SessionOptions | unknown,
|
|
968
|
+
context?: ClassFieldDecoratorContext,
|
|
969
|
+
): SessionDecorator | void {
|
|
970
|
+
if (context !== undefined) {
|
|
971
|
+
return _sessionDecorator({})(optsOrValue, context); // bare @session
|
|
972
|
+
}
|
|
973
|
+
return _sessionDecorator((optsOrValue ?? {}) as SessionOptions);
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
function _sessionDecorator(opts: SessionOptions): SessionDecorator {
|
|
977
|
+
return _fieldDecorator((proto, name) => _registerMap(_sessionProps, proto, name, opts));
|
|
978
|
+
}
|