@zerotal/flow 1.7.0 → 1.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,229 @@
1
+ /**
2
+ * What a time-travel frame opens into, as a small strip of its own tabs.
3
+ *
4
+ * The same shape a request uses in the devtools list, and for the same reason:
5
+ * the views are alternatives, not a sequence. You are reading the arguments the
6
+ * click sent, *or* the state it moved, *or* the SQL it ran on the server — and
7
+ * stacking them makes you scroll past two to reach the third.
8
+ *
9
+ * Only the views a frame actually has appear, so the strip doubles as the
10
+ * summary: a frame showing `Sent · State 2 · Server · Queries 3` has told you
11
+ * what the action did before you click anything.
12
+ *
13
+ * Pure string-building, with no `window` or `document` at module scope, so the
14
+ * decisions here are testable without a browser — which is the point, since the
15
+ * interesting cases (an action that queried, an action that only logged) depend
16
+ * on a signed-in session that is awkward to drive in a headless one.
17
+ */
18
+ import type { TimelineFrame } from "./timeline.ts";
19
+
20
+ /** What the server did while handling one action, read off its devtools trace. */
21
+ export interface FrameServerCost {
22
+ durationMs: number;
23
+ ip: string | null;
24
+ statusCode: number | null;
25
+ queries: Array<{ sql: string; durationMs: number | null; rowCount: number | null }>;
26
+ logs: Array<{ level: string; text: string }>;
27
+ error: string | null;
28
+ }
29
+
30
+ /** One view of an action: a tab in the frame's own strip. */
31
+ export interface FramePane {
32
+ id: string;
33
+ label: string;
34
+ /** Shown beside the label when the view counts something. */
35
+ count?: number;
36
+ html: string;
37
+ }
38
+
39
+ function esc(s: string): string {
40
+ return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
41
+ }
42
+
43
+ /** A value as the panes show it — compact JSON, truncated rather than wrapped. */
44
+ export function brief(value: unknown, max = 160): string {
45
+ let text: string;
46
+ try {
47
+ text = typeof value === "string" ? value : JSON.stringify(value);
48
+ } catch {
49
+ text = String(value);
50
+ }
51
+ if (text === undefined || text === null) text = String(value);
52
+ return text.length > max ? `${text.slice(0, max)}…` : text;
53
+ }
54
+
55
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
56
+ return typeof v === "object" && v !== null && !Array.isArray(v);
57
+ }
58
+
59
+ /**
60
+ * The value out of a snapshot entry, which is stored as `[value, meta]`.
61
+ *
62
+ * A developer reading a state diff wants `1 → 2`, not `[1,{}] → [2,{}]`: the
63
+ * second slot is the framework's own bookkeeping and only earns space when it
64
+ * holds something.
65
+ */
66
+ export function snapshotValue(entry: unknown): unknown {
67
+ if (!Array.isArray(entry) || entry.length === 0) return entry;
68
+ const [value, meta] = entry as [unknown, unknown];
69
+ const bare =
70
+ meta === undefined || meta === null || (isPlainObject(meta) && !Object.keys(meta).length);
71
+ return bare ? value : { value, meta };
72
+ }
73
+
74
+ /** One `key before → after` row per field the action changed. */
75
+ function stateHtml(frame: TimelineFrame, frames: readonly TimelineFrame[]): string {
76
+ if (!frame.changed.length) return "";
77
+ const prior = frames.filter((f) => f.compId === frame.compId && f.seq < frame.seq).pop();
78
+ const before = prior?.snapshot?.data as Record<string, unknown> | undefined;
79
+ const after = frame.snapshot?.data as Record<string, unknown> | undefined;
80
+
81
+ return frame.changed
82
+ .map((key) => {
83
+ const from = before && key in before ? brief(snapshotValue(before[key]), 60) : "—";
84
+ const to = after && key in after ? brief(snapshotValue(after[key]), 60) : "—";
85
+ return (
86
+ `<div style="display:flex;gap:8px;align-items:baseline;padding:1px 0">` +
87
+ `<span style="min-width:120px;color:var(--cyan)">${esc(key)}</span>` +
88
+ `<span class="dim">${esc(from)}</span>` +
89
+ `<span class="dim">→</span>` +
90
+ `<span style="color:var(--green)">${esc(to)}</span>` +
91
+ `</div>`
92
+ );
93
+ })
94
+ .join("");
95
+ }
96
+
97
+ /** The call the browser made: its arguments, and any client writes flushed with it. */
98
+ function sentHtml(frame: TimelineFrame): string {
99
+ if (!frame.sent) return "";
100
+ const args = frame.sent.args.length
101
+ ? frame.sent.args.map((a) => brief(a, 80)).join(", ")
102
+ : "no arguments";
103
+ const updates = frame.sent.updates ? Object.entries(frame.sent.updates) : [];
104
+ const updateRows = updates
105
+ .map(
106
+ ([k, v]) =>
107
+ `<div style="display:flex;gap:8px;padding:1px 0">` +
108
+ `<span style="min-width:120px;color:var(--cyan)">${esc(k)}</span>` +
109
+ `<span class="dim">${esc(brief(v, 60))}</span></div>`,
110
+ )
111
+ .join("");
112
+ return (
113
+ `<div><span style="color:var(--purple)">${esc(frame.action)}</span>` +
114
+ `<span class="dim">(${esc(args)})</span></div>` +
115
+ (updateRows
116
+ ? `<div class="dim" style="font-size:10px;margin:4px 0 1px">flushed with the call</div>${updateRows}`
117
+ : "")
118
+ );
119
+ }
120
+
121
+ function serverHtml(s: FrameServerCost): string {
122
+ return (
123
+ `<div style="display:flex;gap:10px;flex-wrap:wrap">` +
124
+ `<span class="dim">${s.durationMs}ms</span>` +
125
+ (s.statusCode ? `<span class="dim">status ${s.statusCode}</span>` : "") +
126
+ (s.ip ? `<span class="dim">from ${esc(s.ip)}</span>` : "") +
127
+ `</div>` +
128
+ (s.error ? `<div class="red" style="margin-top:2px">${esc(s.error)}</div>` : "")
129
+ );
130
+ }
131
+
132
+ function queriesHtml(s: FrameServerCost): string {
133
+ return s.queries
134
+ .map(
135
+ (q) =>
136
+ `<div style="display:flex;gap:8px;align-items:baseline;padding:1px 0">` +
137
+ `<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(brief(q.sql, 140))}</span>` +
138
+ `<span class="dim" style="font-size:10px">${q.durationMs ?? "—"}ms` +
139
+ `${q.rowCount === null ? "" : ` · ${q.rowCount} row${q.rowCount === 1 ? "" : "s"}`}</span>` +
140
+ `</div>`,
141
+ )
142
+ .join("");
143
+ }
144
+
145
+ function logsHtml(s: FrameServerCost): string {
146
+ return s.logs
147
+ .map(
148
+ (l) =>
149
+ `<div style="padding:1px 0">` +
150
+ `<span class="dim" style="min-width:44px;display:inline-block">${esc(l.level)}</span>` +
151
+ `${esc(brief(l.text, 140))}</div>`,
152
+ )
153
+ .join("");
154
+ }
155
+
156
+ /**
157
+ * The views a frame has to offer, in reading order.
158
+ *
159
+ * `cost` is null for a frame that never left the browser — a client expression —
160
+ * and that frame says so rather than showing an empty Server pane.
161
+ */
162
+ export function framePanes(
163
+ frame: TimelineFrame,
164
+ frames: readonly TimelineFrame[],
165
+ cost: FrameServerCost | null,
166
+ ): FramePane[] {
167
+ const panes: FramePane[] = [];
168
+
169
+ const sent = sentHtml(frame);
170
+ if (sent) panes.push({ id: "sent", label: "Sent", html: sent });
171
+
172
+ const state = stateHtml(frame, frames);
173
+ if (state) panes.push({ id: "state", label: "State", count: frame.changed.length, html: state });
174
+
175
+ if (!cost) {
176
+ panes.push({
177
+ id: "server",
178
+ label: "Server",
179
+ html: `<div class="dim" style="font-size:11px">Ran in the browser — nothing was sent to the server.</div>`,
180
+ });
181
+ return panes;
182
+ }
183
+
184
+ panes.push({ id: "server", label: "Server", html: serverHtml(cost) });
185
+ if (cost.queries.length) {
186
+ panes.push({
187
+ id: "queries",
188
+ label: "Queries",
189
+ count: cost.queries.length,
190
+ html: queriesHtml(cost),
191
+ });
192
+ }
193
+ if (cost.logs.length) {
194
+ panes.push({ id: "logs", label: "Logs", count: cost.logs.length, html: logsHtml(cost) });
195
+ }
196
+ return panes;
197
+ }
198
+
199
+ /**
200
+ * Which pane is showing, given what the reader last picked.
201
+ *
202
+ * One preference across every frame rather than one each: stepping down a list of
203
+ * actions comparing the SQL of one against the next should keep showing SQL, not
204
+ * return you to the arguments at each step. Falls back to the first pane the
205
+ * frame in hand actually has.
206
+ */
207
+ export function activePane(preferred: string, panes: FramePane[]): string {
208
+ return panes.some((p) => p.id === preferred) ? preferred : (panes[0]?.id ?? "");
209
+ }
210
+
211
+ /** A frame's panes, as the strip plus the one body that is showing. */
212
+ export function renderFramePanes(panes: FramePane[], preferred: string): string {
213
+ if (!panes.length) return "";
214
+ const active = activePane(preferred, panes);
215
+ const strip = panes
216
+ .map(
217
+ (p) =>
218
+ `<button class="dsect${p.id === active ? " on" : ""}" data-fsec="${p.id}">` +
219
+ `${esc(p.label)}` +
220
+ (p.count === undefined ? "" : `<span class="dsec-n">${p.count}</span>`) +
221
+ `</button>`,
222
+ )
223
+ .join("");
224
+ const body = panes.find((p) => p.id === active)?.html ?? "";
225
+ return (
226
+ `<div class="dsecs">${strip}</div>` +
227
+ `<div class="dsec-body" style="padding:6px 12px">${body}</div>`
228
+ );
229
+ }
@@ -523,7 +523,13 @@ export function flowFileUpload(config: { name: string }) {
523
523
  this.error = e.detail.error ?? "Upload failed";
524
524
  }) as EventListener);
525
525
 
526
- on("flow:upload-done", ((e: CustomEvent) => {
526
+ // `-finish`, not `-done`. The bridge dispatches `flow:upload-finish`
527
+ // (`_uploadFiles`) and the guide documents that name; this listener was the
528
+ // only place `flow:upload-done` appeared, and nothing has ever dispatched
529
+ // it. So the success path never ran: `uploading` stayed true after a
530
+ // perfectly good upload and the dropzone read "Uploading… 100%" forever,
531
+ // while the file itself had already been stored and bound.
532
+ on("flow:upload-finish", ((e: CustomEvent) => {
527
533
  if (e.detail.key !== this.name) return;
528
534
  this.uploading = false;
529
535
  this.progress = 100;
@@ -27,6 +27,16 @@ export interface TimelineFrame {
27
27
  /** The component's outer HTML at this frame, for DOM restore on a jump. */
28
28
  html: string | null;
29
29
  ts: number;
30
+ /**
31
+ * What the browser sent to produce this frame — absent for a mount, and for a
32
+ * frame produced by anything other than a dispatched action.
33
+ *
34
+ * Kept so the panel can answer "what did this click actually send" without the
35
+ * developer reading the socket. `updates` is the batch of client-side writes
36
+ * flushed alongside the call, which is why a field can differ between frames
37
+ * without appearing in `args`.
38
+ */
39
+ sent?: { args: unknown[]; updates?: Record<string, unknown> };
30
40
  }
31
41
 
32
42
  const MAX_FRAMES = 250;
@@ -87,6 +97,7 @@ export function recordFrame(input: {
87
97
  action: string;
88
98
  snapshot: Snapshot;
89
99
  html: string | null;
100
+ sent?: { args: unknown[]; updates?: Record<string, unknown> };
90
101
  }): TimelineFrame | null {
91
102
  if (!_enabled) return null;
92
103
  const changed = computeChanged(_lastDataByComp.get(input.compId), input.snapshot.data);
@@ -100,6 +111,7 @@ export function recordFrame(input: {
100
111
  changed,
101
112
  html: input.html,
102
113
  ts: Date.now(),
114
+ ...(input.sent ? { sent: input.sent } : {}),
103
115
  };
104
116
  _frames.push(frame);
105
117
  if (_frames.length > MAX_FRAMES) _frames.shift();
package/src/components.ts CHANGED
@@ -28,6 +28,15 @@ import { jsLiteral } from "./utils.ts";
28
28
  export interface LinkProps {
29
29
  href: string;
30
30
  hover?: boolean;
31
+ /**
32
+ * Prefetch on pointer-down instead of on hover.
33
+ *
34
+ * The right choice for a row in a long list: hover prefetch fires for every
35
+ * link the pointer crosses on its way somewhere else, which on a dense table
36
+ * is a fetch per row nobody chose. Pointer-down fires once, on the one link
37
+ * the reader has committed to, and still lands ahead of the click.
38
+ */
39
+ down?: boolean;
31
40
  current?: boolean;
32
41
  exact?: boolean;
33
42
  preserveScroll?: boolean;
@@ -39,7 +48,8 @@ export interface LinkProps {
39
48
  * that you can override or refine per link.
40
49
  *
41
50
  * @remarks
42
- * `hover` prefetches the target on hover; `current` forces (`true`) or suppresses
51
+ * `hover` prefetches the target on hover and `down` on pointer-down (the one to reach
52
+ * for inside a long list); `current` forces (`true`) or suppresses
43
53
  * (`false`) the active state instead of deriving it from the URL; `exact` restricts
44
54
  * the automatic active state to an exact path match. Any other props pass through to
45
55
  * the `<a>`.
@@ -60,9 +70,10 @@ export interface LinkProps {
60
70
  * @category Navigation & data
61
71
  */
62
72
  export function Link(props: LinkProps & { children?: unknown }): HtmlNode {
63
- const { href, hover, current, exact, preserveScroll, children, ...rest } = props;
73
+ const { href, hover, down, current, exact, preserveScroll, children, ...rest } = props;
64
74
  const out: Record<string, unknown> = { ...rest, href, navigate: true, children };
65
75
  if (hover) out["navigateHover"] = true;
76
+ if (down) out["navigateDown"] = true;
66
77
  if (preserveScroll) out["navigatePreserveScroll"] = true;
67
78
  // `current` overrides the automatic URL-based active state:
68
79
  // false → never active (flow:current.ignore)
package/src/decorators.ts CHANGED
@@ -52,7 +52,7 @@ const _exposedMethods = new WeakMap<object, Set<string>>();
52
52
  const _lockedProps = new WeakMap<object, Set<string>>();
53
53
  const _renderlessMethods = new WeakMap<object, Set<string>>();
54
54
  const _validateRules = new WeakMap<object, Map<string, ValidateBuilder>>();
55
- const _onListeners = new WeakMap<object, Map<string, string>>();
55
+ const _onListeners = new WeakMap<object, Map<ListenerName, string>>();
56
56
  const _urlProps = new WeakMap<object, Map<string, UrlOptions>>();
57
57
  /**
58
58
  * prop -> where its value comes from:
@@ -77,10 +77,10 @@ function _register(map: WeakMap<object, Set<string>>, proto: object, key: string
77
77
  s.add(key);
78
78
  }
79
79
 
80
- function _registerMap<V>(
81
- map: WeakMap<object, Map<string, V>>,
80
+ function _registerMap<V, K = string>(
81
+ map: WeakMap<object, Map<K, V>>,
82
82
  proto: object,
83
- key: string,
83
+ key: K,
84
84
  value: V,
85
85
  ): void {
86
86
  let m = map.get(proto);
@@ -102,8 +102,11 @@ function _collect(map: WeakMap<object, Set<string>>, startProto: object): Set<st
102
102
  return result;
103
103
  }
104
104
 
105
- function _collectMap<V>(map: WeakMap<object, Map<string, V>>, startProto: object): Map<string, V> {
106
- const result = new Map<string, V>();
105
+ function _collectMap<V, K = string>(
106
+ map: WeakMap<object, Map<K, V>>,
107
+ startProto: object,
108
+ ): Map<K, V> {
109
+ const result = new Map<K, V>();
107
110
  let proto: object | null = startProto;
108
111
  while (proto && proto !== Object.prototype) {
109
112
  const m = map.get(proto);
@@ -131,7 +134,8 @@ interface MemberTag {
131
134
  renderless?: boolean;
132
135
  task?: boolean;
133
136
  computed?: boolean;
134
- on?: string;
137
+ /** The event name, or a resolver over the instance — see {@link ListenerName}. */
138
+ on?: ListenerName;
135
139
  }
136
140
 
137
141
  // `unique symbol` (not a plain `symbol`) so the computed-key types below resolve.
@@ -302,11 +306,34 @@ function _drainFields(ctor: unknown): void {
302
306
  // recently enqueued group: a class is drained after its own definition, so among
303
307
  // equal candidates the newest is the one that just defined it, and older equals are
304
308
  // leftovers by construction.
309
+ //
310
+ // Restricted first to groups this class could plausibly own: every name in the
311
+ // group is a field this class DECLARES. A sibling's group is rejected by the
312
+ // names it has that this class does not.
313
+ //
314
+ // Length alone was not enough to separate siblings of one decorated base. Given
315
+ // `NewPage { project }` and `EditPage { project, issue }` over a shared base, a
316
+ // drain of NewPage scanned every group, found `project` at the head of EditPage's
317
+ // group, tied on length with its own single entry, and — ties going to the newest
318
+ // group — claimed EditPage's `project` and spliced it away. EditPage then drained
319
+ // to `issue` alone, its `project` never registered, and route-param seeding skipped
320
+ // the field: a 500 on `this.project.slug` that appeared only when the sibling
321
+ // rendered first. Containment separates them, because `issue` is not a field
322
+ // NewPage declares.
323
+ //
324
+ // Falls back to every group when nothing is fully contained, so partial matches —
325
+ // including entries buffered without metadata, which share one bucket — behave as
326
+ // they did.
327
+ const contained = groups.filter((key) =>
328
+ byGroup.get(key)!.every((i) => ownIndex.has(_pendingFields[i]!.name)),
329
+ );
330
+ const candidates = contained.length > 0 ? contained : groups;
331
+
305
332
  let bestIdxs: number[] | null = null;
306
333
  let bestLen = 0;
307
334
  let bestGroup = -1;
308
- for (let g = 0; g < groups.length; g++) {
309
- const idxs = byGroup.get(groups[g]!)!;
335
+ for (let g = 0; g < candidates.length; g++) {
336
+ const idxs = byGroup.get(candidates[g]!)!;
310
337
 
311
338
  // Longest contiguous run whose names map to strictly-increasing declaration-order
312
339
  // indices (equal allowed only for an identical repeated name — multiple decorators
@@ -433,9 +460,9 @@ export function getValidateRules(instance: object): Map<string, ValidateBuilder>
433
460
  _ensureDrained(instance);
434
461
  return _collectMap(_validateRules, Object.getPrototypeOf(instance) as object);
435
462
  }
436
- export function getListeners(PageClass: { prototype: object }): Map<string, string> {
463
+ export function getListeners(PageClass: { prototype: object }): Map<ListenerName, string> {
437
464
  _scanChain(PageClass.prototype as object);
438
- return _collectMap(_onListeners, PageClass.prototype as object);
465
+ return _collectMap<string, ListenerName>(_onListeners, PageClass.prototype as object);
439
466
  }
440
467
  export function getUrlProps(instance: object): Map<string, UrlOptions> {
441
468
  _ensureDrained(instance);
@@ -923,14 +950,36 @@ export function validate(
923
950
 
924
951
  // ── @on ───────────────────────────────────────────────────────────────────────
925
952
 
953
+ /**
954
+ * What a `@on` listener listens to: a static event name, or a resolver called with the component
955
+ * instance to compute one — the same shape as {@link PresenceChannel} and {@link SharedChannel}.
956
+ *
957
+ * Use the resolver form for a `socket:` channel whose name contains a value only the instance
958
+ * knows. A template literal in a plain string cannot work: the decorator's argument is read off
959
+ * the class, long before any instance exists, so `"socket:issues.${this.id},E"` subscribes to a
960
+ * channel whose name contains those eleven characters.
961
+ *
962
+ * @example
963
+ * ```ts
964
+ * // Static — the channel is the same for every instance.
965
+ * @on("socket:orders,OrderPlaced")
966
+ *
967
+ * // Per-instance — one channel per issue.
968
+ * @on((self) => `socket-private:issues.${self.issue.id},CommentPosted`)
969
+ * ```
970
+ *
971
+ * @category Realtime
972
+ */
973
+ export type ListenerName = LooseEventName | ((self: Record<string, any>) => string); // eslint-disable-line @typescript-eslint/no-explicit-any -- `unknown` would break `self.issue.id`
974
+
926
975
  /**
927
976
  * Binds a method as a cross-component (realtime) event listener. Auto-exposes the method.
928
977
  *
929
978
  * @remarks
930
979
  * When the named event is dispatched (via `dispatch` / `dispatchTo` / `dispatchSelf`, or an
931
- * `echo:…` broadcast), the decorated method is invoked with the event payload. The event name
980
+ * `socket:…` broadcast), the decorated method is invoked with the event payload. The event name
932
981
  * autocompletes to the known {@link FlowEvents} contract while still accepting any string (for
933
- * `echo:…` broadcasts and gradually-typed events). Annotate the handler's parameter with
982
+ * `socket:…` broadcasts and gradually-typed events). Annotate the handler's parameter with
934
983
  * `EventPayload<"name">` to type the payload against the same contract. Because it auto-exposes,
935
984
  * no separate `@expose` is needed. Applies to a method.
936
985
  *
@@ -952,7 +1001,7 @@ export function validate(
952
1001
  * @category Realtime
953
1002
  */
954
1003
  export function on(
955
- eventName: LooseEventName,
1004
+ eventName: ListenerName,
956
1005
  ): (fn: unknown, context: ClassMethodDecoratorContext) => void {
957
1006
  return (_fn: unknown, context: ClassMethodDecoratorContext): void => {
958
1007
  // `@on` implies `@expose`; the tag carries the event name so the scan can rebuild the map.
@@ -966,6 +1015,47 @@ export function on(
966
1015
  };
967
1016
  }
968
1017
 
1018
+ /**
1019
+ * The listener names a component actually subscribes to, resolved against `instance`.
1020
+ *
1021
+ * A static name passes through; a resolver is called with the component, so an
1022
+ * `socket:` channel can carry a value only the instance knows — `issues.417` rather
1023
+ * than the class-level `issues.:id`.
1024
+ *
1025
+ * This exists because the channel name reaches the browser through the snapshot,
1026
+ * and the snapshot is built per instance. Registering `(self) => …` under the
1027
+ * *class* and resolving here is the same split {@link presence} and {@link shared}
1028
+ * already use; before it, a `@on("socket-private:issues.${this.issueId},…")`
1029
+ * written the way the guide showed subscribed to a channel literally containing
1030
+ * `${this.issueId}` — no error, no warning, and no events, because template
1031
+ * syntax inside a plain string is just text.
1032
+ *
1033
+ * A resolver that throws is dropped rather than allowed to fail the render: a
1034
+ * page whose live updates do not arrive is a degraded page, while a page that
1035
+ * 500s because one channel could not be named is no page at all.
1036
+ */
1037
+ export function resolveListeners(
1038
+ listeners: Map<ListenerName, string>,
1039
+ instance: object,
1040
+ ): Map<string, string> {
1041
+ const resolved = new Map<string, string>();
1042
+
1043
+ for (const [name, method] of listeners) {
1044
+ if (typeof name !== "function") {
1045
+ resolved.set(name, method);
1046
+ continue;
1047
+ }
1048
+ try {
1049
+ const channel = name(instance as Record<string, unknown>);
1050
+ if (typeof channel === "string" && channel !== "") resolved.set(channel, method);
1051
+ } catch {
1052
+ /* an unresolvable channel is not subscribed to */
1053
+ }
1054
+ }
1055
+
1056
+ return resolved;
1057
+ }
1058
+
969
1059
  // ── @url ──────────────────────────────────────────────────────────────────────
970
1060
 
971
1061
  /**
package/src/dehydrate.ts CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  getLockedProps,
9
9
  getUrlProps,
10
10
  getListeners,
11
+ resolveListeners,
11
12
  getPresenceProps,
12
13
  resolvePresenceChannel,
13
14
  getSharedProps,
@@ -124,9 +125,14 @@ export function dehydrate(
124
125
  data[key] = [serialized, meta];
125
126
  }
126
127
 
127
- // Collect @on listeners for the client event router.
128
+ // Collect @on listeners for the client event router, resolving any registered as
129
+ // `(self) => name` against this instance — that is what lets a `socket:` channel carry
130
+ // a per-record id, since the decorator's argument is read off the class.
128
131
  // Excluded from HMAC signing (server-derived, can't usefully be tampered with).
129
- const listenersMap = getListeners(page.constructor as { prototype: object });
132
+ const listenersMap = resolveListeners(
133
+ getListeners(page.constructor as { prototype: object }),
134
+ page,
135
+ );
130
136
  const listeners = listenersMap.size > 0 ? Object.fromEntries(listenersMap) : undefined;
131
137
 
132
138
  // Named slot HTML passed down by the parent. Signed (server-generated), and carried
@@ -216,6 +222,49 @@ export async function hydrate<T extends Component>(
216
222
  return page;
217
223
  }
218
224
 
225
+ /**
226
+ * A copy of `snapshot` with client-supplied hidden values removed, re-signed.
227
+ *
228
+ * A hidden field the user typed — a new password — rides in the snapshot so it survives until
229
+ * they save. That is fine on the wire, where it goes back to the browser that produced it,
230
+ * and wrong in the durable store, which writes the whole snapshot server-side after every
231
+ * request and may be backed by Redis. A half-typed password should not be waiting there for
232
+ * anyone, and resuming into one is not what durable is for.
233
+ *
234
+ * Re-signed rather than edited in place: the checksum covers `data`, so a strip without a
235
+ * fresh signature would produce a snapshot that fails its own verification on restore.
236
+ *
237
+ * @internal
238
+ */
239
+ export function stripPendingSecrets(snapshot: Snapshot): Snapshot {
240
+ let touched = false;
241
+ const data: SnapshotData = {};
242
+
243
+ for (const [prop, [value, meta]] of Object.entries(snapshot.data)) {
244
+ const pending = meta["p"];
245
+ if (
246
+ !Array.isArray(pending) ||
247
+ pending.length === 0 ||
248
+ value === null ||
249
+ typeof value !== "object"
250
+ ) {
251
+ data[prop] = [value, meta];
252
+ continue;
253
+ }
254
+ touched = true;
255
+ const cleanValue: Record<string, unknown> = { ...(value as Record<string, unknown>) };
256
+ for (const key of pending as string[]) delete cleanValue[key];
257
+ const cleanMeta = { ...meta };
258
+ delete cleanMeta["p"];
259
+ data[prop] = [cleanValue, cleanMeta];
260
+ }
261
+
262
+ if (!touched) return snapshot;
263
+
264
+ const stripped = { ...snapshot, data };
265
+ return { ...stripped, checksum: _sign(_signingPayload(stripped)) };
266
+ }
267
+
219
268
  /** Verify a snapshot's HMAC against the current APP_KEY. Public so durable-snapshot
220
269
  * restore can validate a stored snapshot before trusting it (a rotated key → false). */
221
270
  export function verifySnapshot(snapshot: Snapshot): boolean {
package/src/durable.ts CHANGED
@@ -13,7 +13,7 @@
13
13
  import type { Component } from "./Component.ts";
14
14
  import type { Snapshot } from "./types.ts";
15
15
  import type { HttpContext } from "@zerotal/core";
16
- import { verifySnapshot, applySnapshotData } from "./dehydrate.ts";
16
+ import { verifySnapshot, applySnapshotData, stripPendingSecrets } from "./dehydrate.ts";
17
17
 
18
18
  const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000; // 24h
19
19
 
@@ -221,7 +221,10 @@ export async function persistDurable(
221
221
  await _store.delete(key);
222
222
  return;
223
223
  }
224
- await _store.set(key, snapshot, cfg.ttlMs);
224
+ // Never persist a hidden field the client is part-way through typing — see
225
+ // `stripPendingSecrets`. The wire may carry it back to the browser that produced it; a
226
+ // server-side store, possibly Redis, may not hold it.
227
+ await _store.set(key, stripPendingSecrets(snapshot), cfg.ttlMs);
225
228
  }
226
229
 
227
230
  /**
package/src/events.ts CHANGED
@@ -13,7 +13,7 @@
13
13
  // }
14
14
  // }
15
15
  //
16
- // Unknown event names still work (a `string` fallback overload keeps `echo:…` broadcasts and
16
+ // Unknown event names still work (a `string` fallback overload keeps `socket:…` broadcasts and
17
17
  // gradual adoption compiling), they're just untyped. Payloads are object shapes or `void`.
18
18
 
19
19
  /**
@@ -24,7 +24,7 @@
24
24
  *
25
25
  * @remarks
26
26
  * Payloads are object shapes or `void` (for events with no payload). Unknown event names still
27
- * compile (a `string` fallback keeps `echo:…` broadcasts and gradual adoption working); they are
27
+ * compile (a `string` fallback keeps `socket:…` broadcasts and gradual adoption working); they are
28
28
  * simply untyped.
29
29
  *
30
30
  * @example
@@ -64,7 +64,7 @@ export type EventPayload<K extends EventName> = FlowEvents[K];
64
64
  */
65
65
  export type EventArgs<P> = [P] extends [void] ? [] : [payload: P];
66
66
 
67
- /** An event name loosened to accept any string (autocomplete for known names, `echo:…` too). */
67
+ /** An event name loosened to accept any string (autocomplete for known names, `socket:…` too). */
68
68
  export type LooseEventName = EventName | (string & {});
69
69
 
70
70
  // ── Optional runtime shape guards ──────────────────────────────────────────────
package/src/index.ts CHANGED
@@ -189,6 +189,7 @@ export { renderless } from "./decorators.ts";
189
189
  export { task } from "./decorators.ts";
190
190
  export { validate } from "./decorators.ts";
191
191
  export { on } from "./decorators.ts";
192
+ export type { ListenerName } from "./decorators.ts";
192
193
  export { url } from "./decorators.ts";
193
194
  export { param } from "./decorators.ts";
194
195
  export { session } from "./decorators.ts";
@@ -59,6 +59,7 @@ interface FlowDirectives {
59
59
 
60
60
  navigate?: boolean;
61
61
  navigateHover?: boolean;
62
+ navigateDown?: boolean;
62
63
  navigatePreserveScroll?: boolean;
63
64
  current?: string;
64
65
  currentExact?: boolean;
@@ -147,9 +148,20 @@ interface FlowDirectives {
147
148
  show?: any;
148
149
  showImportant?: boolean;
149
150
 
151
+ /**
152
+ * The reorder action, as a method reference or its name.
153
+ *
154
+ * Read off the container a child was **dropped into** and called `(key, index)`,
155
+ * so the destination is encoded in which method runs and nowhere in the payload.
156
+ * Dragging between containers therefore needs one action per container.
157
+ */
150
158
  onSort?: any;
151
159
  sortItem?: any;
152
160
  sortGroup?: string;
161
+ /**
162
+ * Emitted as `flow:sort:group-id`, and **currently read by nothing** — neither the
163
+ * client runtime nor the compiler looks for it. Setting it has no effect today.
164
+ */
153
165
  sortGroupId?: any;
154
166
  sortHandle?: boolean;
155
167
  sortIgnore?: boolean;
@@ -738,6 +750,7 @@ const PULSE_PROP_MAP: Record<string, string> = {
738
750
  dirtyTarget: "flow:target",
739
751
  navigate: "flow:navigate",
740
752
  navigateHover: "flow:navigate.hover",
753
+ navigateDown: "flow:navigate.down",
741
754
  // One word on purpose: toFlowHtmlAttr() rewrites every `-` in a flow: attribute
742
755
  // to a `.`, so a hyphenated modifier would reach the DOM under a different name
743
756
  // here than the compiler emits.