@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.
Files changed (64) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/LICENSE +21 -0
  3. package/README.md +148 -0
  4. package/package.json +84 -0
  5. package/src/Component.ts +1498 -0
  6. package/src/Form.ts +189 -0
  7. package/src/Layout.ts +47 -0
  8. package/src/augment.ts +94 -0
  9. package/src/client/FlowClientError.ts +12 -0
  10. package/src/client/FlowComponent.ts +96 -0
  11. package/src/client/alpinejs-morph.d.ts +5 -0
  12. package/src/client/bridge.ts +3463 -0
  13. package/src/client/confirm.ts +338 -0
  14. package/src/client/cspAdapter.ts +79 -0
  15. package/src/client/cspEvaluator.ts +494 -0
  16. package/src/client/errorOverlay.ts +124 -0
  17. package/src/client/headless.ts +948 -0
  18. package/src/client/index.csp.ts +47 -0
  19. package/src/client/index.ts +56 -0
  20. package/src/client/optimistic.ts +52 -0
  21. package/src/client/timeline.ts +160 -0
  22. package/src/client/url.ts +51 -0
  23. package/src/commands/MakeFlowCommand.ts +290 -0
  24. package/src/compiler/cache.ts +101 -0
  25. package/src/compiler/directives.ts +107 -0
  26. package/src/compiler/helpers.ts +26 -0
  27. package/src/compiler/index.ts +172 -0
  28. package/src/compiler/transform.ts +2070 -0
  29. package/src/components.ts +1465 -0
  30. package/src/config.ts +52 -0
  31. package/src/decorators.ts +978 -0
  32. package/src/dehydrate.ts +333 -0
  33. package/src/durable.ts +243 -0
  34. package/src/events.ts +118 -0
  35. package/src/frameworkEvents.ts +40 -0
  36. package/src/headless.ts +1147 -0
  37. package/src/index.ts +267 -0
  38. package/src/jsx-dev-runtime.ts +14 -0
  39. package/src/jsx-runtime.ts +1272 -0
  40. package/src/mixins.ts +199 -0
  41. package/src/observability.ts +167 -0
  42. package/src/pagination.ts +300 -0
  43. package/src/presence.ts +81 -0
  44. package/src/provider/FlowProvider.ts +1401 -0
  45. package/src/registry.ts +118 -0
  46. package/src/renderContext.ts +55 -0
  47. package/src/router.ts +401 -0
  48. package/src/shared.ts +226 -0
  49. package/src/store.ts +116 -0
  50. package/src/synths/CarbonIntervalSynth.ts +22 -0
  51. package/src/synths/CarbonSynth.ts +20 -0
  52. package/src/synths/CollectionSynth.ts +62 -0
  53. package/src/synths/DateSynth.ts +24 -0
  54. package/src/synths/ModelSynth.ts +78 -0
  55. package/src/synths/TemporaryUploadedFileSynth.ts +19 -0
  56. package/src/synths/index.ts +148 -0
  57. package/src/testing.ts +361 -0
  58. package/src/types.ts +315 -0
  59. package/src/uploads/FileUploads.ts +94 -0
  60. package/src/uploads/TemporaryUploadedFile.ts +274 -0
  61. package/src/uploads/sign.ts +18 -0
  62. package/src/urlSafety.ts +0 -0
  63. package/src/utils.ts +216 -0
  64. package/src/validation.ts +55 -0
@@ -0,0 +1,3463 @@
1
+ // ── Flow WS bridge ───────────────────────────────────────────────────────────
2
+ //
3
+ // Responsibilities:
4
+ // 1. Scan the DOM for [data-flow-root] and initialise FlowComponent instances
5
+ // (including nested child components, each an isolated island)
6
+ // 2. Open a WebSocket to /__flow/ws with exponential back-off reconnect
7
+ // 3. Event delegation for flow:click, flow:submit, flow:model, flow:confirm
8
+ // 4. Loading state (flow:loading family, with flow:target scoping + .delay)
9
+ // 5. Dirty state (flow:dirty family — local edits not yet confirmed by server)
10
+ // 6. Offline state (flow:offline family + body[data-flow-connection])
11
+ // 7. Per-component outbound frame queue (serialised — no race conditions)
12
+ // 8. Apply server patches via Alpine.morph (respecting flow:ignore /
13
+ // flow:ignore.self / flow:replace, skipping nested component roots)
14
+ // 9. flow:cloak removal on init; flow:poll interval setup
15
+ // 10. Declarative bindings: flow:text, flow:show, flow:bind:class/href/attr
16
+ // 11. flow:intersect viewport actions; flow:sort drag-and-drop reordering
17
+ // 12. flow:stream progressive content frames (mid-action server pushes)
18
+ // 13. flow:navigate SPA navigation (+ .hover prefetch, flow:current links)
19
+ // 14. Update the $flow Alpine store after every patch
20
+ // 15. $errors Alpine magic for validation error bags
21
+ // 16. Cross-component events via CustomEvent + window dispatch
22
+ // 17. @url query string sync on every patch
23
+ // 18. Lazy loading (IntersectionObserver) and deferred loading (DOMContentLoaded)
24
+ // 19. File downloads via blob URL
25
+ // 20. flow:transition enter animations on morph
26
+ // 21. flow:teleport — render content at a different DOM location
27
+ // 22. Offline action queue — multiple actions per component queued offline
28
+
29
+ import type { Snapshot, SnapshotData, SnapshotMemo, ServerFrame } from "../types.ts";
30
+ import { FlowComponent } from "./FlowComponent.ts";
31
+ import { evaluateCsp } from "./cspEvaluator.ts";
32
+ import { buildUrlWithQuery, type CurrentUrlOptions } from "./url.ts";
33
+ import { confirmDialog, type ConfirmDialogOptions } from "./confirm.ts";
34
+ import { showErrorOverlay } from "./errorOverlay.ts";
35
+ import { applyAppend, applyRemove, type OptOp } from "./optimistic.ts";
36
+ import { clientStore } from "../store.ts";
37
+ import { URL_ATTRIBUTES, sanitizeUrl } from "../urlSafety.ts";
38
+ import {
39
+ recordFrame as _recordTimelineFrame,
40
+ isTimelineEnabled,
41
+ setTimelineEnabled,
42
+ setTimelineApplier,
43
+ } from "./timeline.ts";
44
+ import * as _timeline from "./timeline.ts";
45
+
46
+ // CSP-safe mode: set by the CSP client entry. When on, bridge-managed bindings
47
+ // use the eval-free evaluateCsp() instead of `new Function`.
48
+ let _bridgeCsp = false;
49
+ /** @internal Enable CSP-safe evaluation in the bridge (called by the CSP client entry). */
50
+ export function setCspMode(on: boolean): void {
51
+ _bridgeCsp = on;
52
+ }
53
+
54
+ // Holds the $flow proxy builder (assigned in registerFlowMagic) so the CSP
55
+ // evaluator adapter can resolve `$flow` without going through Alpine's evaluator.
56
+ let _gelMagicBuilder: ((el: Element) => unknown) | null = null;
57
+ /** @internal Build the $flow proxy for an element (used by the CSP evaluator scope). */
58
+ export function _resolveGel(el: Element): unknown {
59
+ return _gelMagicBuilder ? _gelMagicBuilder(el) : undefined;
60
+ }
61
+
62
+ type AlpineType = {
63
+ reactive(o: object): Record<string, unknown>;
64
+ morph(from: Element, to: string | Element, config?: object): void;
65
+ store(name: string, init?: object): unknown;
66
+ magic(name: string, fn: (el: Element) => unknown): void;
67
+ };
68
+
69
+ declare const Alpine: AlpineType;
70
+
71
+ const PULSE_CSS = `
72
+ [flow\\:cloak] { display: none !important; }
73
+ [x-cloak] { display: none !important; }
74
+ [flow\\:loading] { display: none; }
75
+ /* Visual loading indicators wait out a short delay so a very fast action never flashes one.
76
+ The delayed marker is set LOADING_DELAY_MS after the action starts (see _setLoading), and
77
+ cleared the moment it resolves — so an action that finishes inside the window shows nothing. */
78
+ [data-flow-loading-delayed] [flow\\:loading] { display: revert; }
79
+ [data-flow-loading-delayed] [flow\\:loading\\.remove] { display: none !important; }
80
+ [flow\\:loading\\.delay] { display: none; }
81
+ [data-flow-loading-delayed] [flow\\:loading\\.delay] { display: revert; }
82
+ [flow\\:loading][flow\\:target],
83
+ [flow\\:loading][flow\\:target\\.except] { display: none; }
84
+ [flow\\:dirty] { display: none; }
85
+ [flow\\:offline] { display: none; }
86
+ body[data-flow-connection="offline"] [flow\\:offline] { display: revert; }
87
+ body[data-flow-connection="offline"] [flow\\:offline\\.remove] { display: none !important; }
88
+ [data-flow-lazy-placeholder] { min-height: 1px; }
89
+ [flow\\:transition] { transition: opacity var(--flow-transition-duration, 200ms) ease, transform var(--flow-transition-duration, 200ms) ease; }
90
+ [flow\\:transition].flow-entering { opacity: 0; transform: translateY(4px); }
91
+ /* show= enter/leave presets for Alpine's x-transition. The compiler emits the transition prop on a
92
+ show= element as x-transition:enter/leave="flow-t-PRESET" + enter-start/leave-end="flow-t-out". The
93
+ preset class carries the transition timing; .flow-t-PRESET.flow-t-out is the hidden state. */
94
+ .flow-t-fade, .flow-t-scale, .flow-t-slide-up,
95
+ .flow-t-slide-down, .flow-t-slide-left, .flow-t-slide-right { transition: opacity var(--flow-transition-duration, 200ms) ease, transform var(--flow-transition-duration, 200ms) ease; }
96
+ .flow-t-fade.flow-t-out { opacity: 0; }
97
+ .flow-t-scale.flow-t-out { opacity: 0; transform: scale(0.95); }
98
+ .flow-t-slide-up.flow-t-out { opacity: 0; transform: translateY(8px); }
99
+ .flow-t-slide-down.flow-t-out { opacity: 0; transform: translateY(-8px); }
100
+ .flow-t-slide-left.flow-t-out { opacity: 0; transform: translateX(8px); }
101
+ .flow-t-slide-right.flow-t-out { opacity: 0; transform: translateX(-8px); }
102
+ @media (prefers-reduced-motion: reduce) {
103
+ .flow-t-fade, .flow-t-scale, .flow-t-slide-up,
104
+ .flow-t-slide-down, .flow-t-slide-left, .flow-t-slide-right { transition: none; }
105
+ }
106
+ [flow\\:failed] { display: none; }
107
+ [data-flow-action-error] [flow\\:failed] { display: revert; }
108
+ [data-flow-action-error] [flow\\:failed\\.remove] { display: none !important; }
109
+ .flow-skeleton { background: var(--flow-skeleton-color, rgba(0,0,0,0.08)); animation: flow-skeleton-pulse 1.5s ease-in-out infinite; }
110
+ @keyframes flow-skeleton-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.45; } }
111
+ @media (prefers-reduced-motion: reduce) { .flow-skeleton { animation: none; } }
112
+ `;
113
+
114
+ function _escapeHtml(s: string): string {
115
+ return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
116
+ }
117
+
118
+ function _injectFlowStyles(): void {
119
+ if (document.getElementById("flow-styles")) return;
120
+ const style = document.createElement("style");
121
+ style.id = "flow-styles";
122
+ style.textContent = PULSE_CSS;
123
+ document.head.appendChild(style);
124
+ }
125
+
126
+ const _components = new Map<string, FlowComponent>();
127
+
128
+ // Bare→`$`-prefixed aliases for the small set of magics that are ALSO real Component
129
+ // methods (`this.refresh()` / `this.dispatch()` …). These names are already owned by the
130
+ // base class, so promoting the bare form to the `$`-magic is safe and can't shadow a
131
+ // developer's own member. Every OTHER client magic lives ONLY under its `$`-prefixed name
132
+ // on `$flow` (`$flow.$set`, `$flow.$store`, `$flow.$parent`, …), leaving the bare names
133
+ // (`set`, `toggle`, `store`, `parent`, …) free for the developer's own props/methods.
134
+ // Keep in sync with `CLIENT_CALLBACK_MAGICS` in compiler/transform.ts.
135
+ const _MAGIC_ALIASES = new Set(["refresh", "dispatch", "dispatchTo", "dispatchSelf"]);
136
+
137
+ // Reactive/modelable bindings (Tier 1). `_modelMaps`: childId → { childProp: parentProp }.
138
+ // `_modelBaselines`: childId → { prop: lastSeenJSON } — guards against echo loops so a
139
+ // parent-pushed value isn't propagated straight back to the parent (and vice-versa).
140
+ const _modelMaps = new Map<string, Record<string, string>>();
141
+ const _modelBaselines = new Map<string, Map<string, string>>();
142
+
143
+ function _modelBaselineFor(id: string): Map<string, string> {
144
+ let b = _modelBaselines.get(id);
145
+ if (!b) {
146
+ b = new Map();
147
+ _modelBaselines.set(id, b);
148
+ }
149
+ return b;
150
+ }
151
+
152
+ /**
153
+ * Apply parent→child reactive bindings carried on a child root's stub during morph.
154
+ * Reads `data-flow-model` (records the child→parent map) and `data-flow-props` (parent-
155
+ * pushed @reactive values); for each changed value, updates the child locally and
156
+ * round-trips a $set so the child re-renders. Baselines are recorded so the change
157
+ * isn't echoed back to the parent by `_propagateModelable`.
158
+ */
159
+ function _applyChildBindings(from: Element, to: Element): void {
160
+ const childId = (from as HTMLElement).dataset["flowId"];
161
+ if (!childId) return;
162
+ const child = _components.get(childId);
163
+ if (!child) return;
164
+
165
+ const modelRaw = to.getAttribute("data-flow-model");
166
+ if (modelRaw) {
167
+ try {
168
+ _modelMaps.set(childId, JSON.parse(modelRaw) as Record<string, string>);
169
+ } catch {
170
+ /* ignore */
171
+ }
172
+ }
173
+
174
+ const propsRaw = to.getAttribute("data-flow-props");
175
+ if (!propsRaw) return;
176
+ let props: Record<string, unknown>;
177
+ try {
178
+ props = JSON.parse(propsRaw) as Record<string, unknown>;
179
+ } catch {
180
+ return;
181
+ }
182
+
183
+ const baseline = _modelBaselineFor(childId);
184
+ for (const [k, v] of Object.entries(props)) {
185
+ const nextJson = JSON.stringify(v);
186
+ baseline.set(k, nextJson); // parent-originated → suppress echo
187
+ if (JSON.stringify(child.reactive[k]) === nextJson) continue;
188
+ child.reactive[k] = v; // reactive first (proxy), then raw target
189
+ child.ephemeral[k] = v;
190
+ _callAction(child, "$set", [k, v]); // re-render child with the new prop
191
+ }
192
+ }
193
+
194
+ /**
195
+ * After a child's patch lands, push any genuinely child-originated change to a
196
+ * @modelable prop back up to the bound parent property (child→parent half of the
197
+ * two-way binding). The baseline guard ensures parent-pushed values don't bounce back.
198
+ */
199
+ function _propagateModelable(comp: FlowComponent): void {
200
+ const map = _modelMaps.get(comp.id);
201
+ if (!map) return;
202
+ const parentEl = comp.rootEl.parentElement?.closest<HTMLElement>("[data-flow-root]");
203
+ const parentId = parentEl?.dataset["flowId"];
204
+ const parent = parentId ? _components.get(parentId) : undefined;
205
+ if (!parent) return;
206
+
207
+ const baseline = _modelBaselineFor(comp.id);
208
+ for (const [childProp, parentProp] of Object.entries(map)) {
209
+ const curJson = JSON.stringify(comp.reactive[childProp]);
210
+ if (baseline.get(childProp) === curJson) continue; // unchanged or parent-originated
211
+ baseline.set(childProp, curJson);
212
+ const val = comp.reactive[childProp];
213
+ parent.reactive[parentProp] = val; // reactive first, then raw target
214
+ parent.ephemeral[parentProp] = val;
215
+ _callAction(parent, "$set", [parentProp, val]);
216
+ }
217
+ }
218
+
219
+ function findComponentByEl(el: Element): FlowComponent | null {
220
+ // Teleported elements live outside [data-flow-root] — look for a saved owner id
221
+ const teleportRoot = el.closest("[data-flow-teleported]") as HTMLElement | null;
222
+ if (teleportRoot) {
223
+ const ownerId = teleportRoot.dataset["flowOwner"];
224
+ if (ownerId) return _components.get(ownerId) ?? null;
225
+ }
226
+
227
+ let root = el.closest("[data-flow-root]") as HTMLElement | null;
228
+ while (root) {
229
+ const id = root.dataset["flowId"];
230
+ if (id) return _components.get(id) ?? null;
231
+ root = (root.parentElement?.closest("[data-flow-root]") as HTMLElement | null) ?? null;
232
+ }
233
+ return null;
234
+ }
235
+
236
+ function _owns(comp: FlowComponent, el: Element): boolean {
237
+ return el.closest("[data-flow-root]") === comp.rootEl;
238
+ }
239
+
240
+ // Dotted-path get/set for nested model binding (e.g. Form fields: "form.email").
241
+ function _getPath(obj: Record<string, unknown>, path: string): unknown {
242
+ if (!path.includes(".")) return obj?.[path];
243
+ return path
244
+ .split(".")
245
+ .reduce<unknown>((o, k) => (o == null ? undefined : (o as Record<string, unknown>)[k]), obj);
246
+ }
247
+ function _setPath(obj: Record<string, unknown>, path: string, value: unknown): void {
248
+ if (!path.includes(".")) {
249
+ obj[path] = value;
250
+ return;
251
+ }
252
+ const parts = path.split(".");
253
+ let o = obj as Record<string, unknown>;
254
+ for (let i = 0; i < parts.length - 1; i++) {
255
+ const k = parts[i]!;
256
+ if (o[k] == null || typeof o[k] !== "object") o[k] = {};
257
+ o = o[k] as Record<string, unknown>;
258
+ }
259
+ o[parts[parts.length - 1]!] = value;
260
+ }
261
+
262
+ function _ownedEls(comp: FlowComponent, selector: string): Element[] {
263
+ const els = Array.from(comp.rootEl.querySelectorAll(selector)).filter((el) => _owns(comp, el));
264
+ if (comp.rootEl.matches(selector)) els.unshift(comp.rootEl);
265
+ // Teleported subtrees live outside rootEl (moved to e.g. <body>) but still belong
266
+ // to this component — include them so flow:show / flow:text / flow:bind keep working.
267
+ document
268
+ .querySelectorAll(`[data-flow-teleported][data-flow-owner="${comp.id}"]`)
269
+ .forEach((root) => {
270
+ if (root.matches(selector)) els.push(root);
271
+ root.querySelectorAll(selector).forEach((e) => els.push(e));
272
+ });
273
+ return els;
274
+ }
275
+
276
+ const _loadingTimers = new Map<string, ReturnType<typeof setTimeout>>();
277
+ const LOADING_DELAY_MS = 200;
278
+
279
+ // A `live` text input debounces its per-keystroke sync by default, so fast typing doesn't fire
280
+ // a WebSocket $set on every character (matching Livewire's `wire:model.live` default). Discrete
281
+ // controls — checkbox, radio, select, range, date, colour — send immediately (a deliberate pick,
282
+ // not a stream of keystrokes). The default binding (no `live`) is deferred and never affected.
283
+ const MODEL_DEBOUNCE_MS = 150;
284
+ const _TEXTLIKE_INPUT = new Set([
285
+ "text",
286
+ "email",
287
+ "password",
288
+ "search",
289
+ "url",
290
+ "tel",
291
+ "number",
292
+ "textarea",
293
+ ]);
294
+
295
+ /** Debounce for a `live` input's keystroke sync: text-like → MODEL_DEBOUNCE_MS, else immediate. */
296
+ function _modelDebounceMs(el: HTMLInputElement): number {
297
+ return _TEXTLIKE_INPUT.has((el.type || "text").toLowerCase()) ? MODEL_DEBOUNCE_MS : 0;
298
+ }
299
+
300
+ function _targetsMatch(el: Element, action: string | null): boolean {
301
+ const except = el.getAttribute("flow:target.except");
302
+ if (
303
+ except &&
304
+ action &&
305
+ except
306
+ .split(",")
307
+ .map((s) => s.trim())
308
+ .includes(action)
309
+ ) {
310
+ return false;
311
+ }
312
+ const target = el.getAttribute("flow:target");
313
+ if (target) {
314
+ if (!action) return false;
315
+ return target
316
+ .split(",")
317
+ .map((s) => s.trim())
318
+ .includes(action);
319
+ }
320
+ return true;
321
+ }
322
+
323
+ /**
324
+ * Attribute toggles (`loadingAttr="disabled"`) are applied IMMEDIATELY — a disabled submit
325
+ * button must guard against a double-click even for a sub-100ms action.
326
+ */
327
+ function _applyLoadingAttr(comp: FlowComponent, loading: boolean, action: string | null): void {
328
+ _ownedEls(comp, "[flow\\:loading\\.attr]").forEach((el) => {
329
+ const attr = el.getAttribute("flow:loading.attr");
330
+ if (!attr) return;
331
+ const active = loading && _targetsMatch(el, action);
332
+ if (active) el.setAttribute(attr, "");
333
+ else el.removeAttribute(attr);
334
+ });
335
+ }
336
+
337
+ /**
338
+ * Visual indicators (targeted show/hide, `loadingClass`) — the flashy ones. Applied only after
339
+ * the delay on the way in, and cleared immediately on the way out, so a fast action shows none.
340
+ */
341
+ function _applyLoadingVisuals(comp: FlowComponent, loading: boolean, action: string | null): void {
342
+ _ownedEls(
343
+ comp,
344
+ "[flow\\:loading][flow\\:target], [flow\\:loading][flow\\:target\\.except]",
345
+ ).forEach((el) => {
346
+ const show = loading && _targetsMatch(el, action);
347
+ (el as HTMLElement).style.display = show ? el.getAttribute("flow:loading") || "revert" : "";
348
+ });
349
+ _ownedEls(
350
+ comp,
351
+ "[flow\\:loading\\.remove][flow\\:target], [flow\\:loading\\.remove][flow\\:target\\.except]",
352
+ ).forEach((el) => {
353
+ const hide = loading && _targetsMatch(el, action);
354
+ (el as HTMLElement).style.display = hide ? "none" : "";
355
+ });
356
+
357
+ _ownedEls(comp, "[flow\\:loading\\.class]").forEach((el) => {
358
+ const cls = el.getAttribute("flow:loading.class");
359
+ if (!cls) return;
360
+ const active = loading && _targetsMatch(el, action);
361
+ cls
362
+ .split(/\s+/)
363
+ .filter(Boolean)
364
+ .forEach((c) => el.classList.toggle(c, active));
365
+ });
366
+
367
+ _ownedEls(comp, "[flow\\:loading\\.class\\.remove]").forEach((el) => {
368
+ const cls = el.getAttribute("flow:loading.class.remove");
369
+ if (!cls) return;
370
+ const active = loading && _targetsMatch(el, action);
371
+ cls
372
+ .split(/\s+/)
373
+ .filter(Boolean)
374
+ .forEach((c) => el.classList.toggle(c, !active));
375
+ });
376
+ }
377
+
378
+ function _setLoading(comp: FlowComponent, loading: boolean, action: string | null = null): void {
379
+ if (loading) {
380
+ comp.rootEl.setAttribute("data-flow-loading", "");
381
+ // Disable now (guards double-submit); reveal spinners/classes only if the action
382
+ // outlasts the delay — a quick action never flashes a loading indicator.
383
+ _applyLoadingAttr(comp, true, action);
384
+ clearTimeout(_loadingTimers.get(comp.id));
385
+ _loadingTimers.set(
386
+ comp.id,
387
+ setTimeout(() => {
388
+ comp.rootEl.setAttribute("data-flow-loading-delayed", "");
389
+ _applyLoadingVisuals(comp, true, action);
390
+ }, LOADING_DELAY_MS),
391
+ );
392
+ } else {
393
+ comp.rootEl.removeAttribute("data-flow-loading");
394
+ comp.rootEl.removeAttribute("data-flow-loading-delayed");
395
+ clearTimeout(_loadingTimers.get(comp.id));
396
+ _loadingTimers.delete(comp.id);
397
+ _applyLoadingAttr(comp, false, action);
398
+ _applyLoadingVisuals(comp, false, action); // clears anything the timer had already applied
399
+ }
400
+ }
401
+
402
+ function _dirtyKeys(comp: FlowComponent): Set<string> {
403
+ const keys = new Set<string>();
404
+ for (const [key, val] of Object.entries(comp.ephemeral)) {
405
+ if (JSON.stringify(comp.canonical[key]) !== JSON.stringify(val)) keys.add(key);
406
+ }
407
+ return keys;
408
+ }
409
+
410
+ function _dirtyMatch(el: Element, dirty: Set<string>): boolean {
411
+ const target = el.getAttribute("flow:target");
412
+ if (target) return target.split(",").some((k) => dirty.has(k.trim()));
413
+ return dirty.size > 0;
414
+ }
415
+
416
+ function _updateDirty(comp: FlowComponent): void {
417
+ const dirty = _dirtyKeys(comp);
418
+
419
+ _ownedEls(comp, "[flow\\:dirty]").forEach((el) => {
420
+ const show = _dirtyMatch(el, dirty);
421
+ (el as HTMLElement).style.display = show ? el.getAttribute("flow:dirty") || "revert" : "";
422
+ });
423
+ _ownedEls(comp, "[flow\\:dirty\\.remove]").forEach((el) => {
424
+ (el as HTMLElement).style.display = _dirtyMatch(el, dirty) ? "none" : "";
425
+ });
426
+ _ownedEls(comp, "[flow\\:dirty\\.class]").forEach((el) => {
427
+ const cls = el.getAttribute("flow:dirty.class");
428
+ if (!cls) return;
429
+ const active = _dirtyMatch(el, dirty);
430
+ cls
431
+ .split(/\s+/)
432
+ .filter(Boolean)
433
+ .forEach((c) => el.classList.toggle(c, active));
434
+ });
435
+ _ownedEls(comp, "[flow\\:dirty\\.class\\.remove]").forEach((el) => {
436
+ const cls = el.getAttribute("flow:dirty.class.remove");
437
+ if (!cls) return;
438
+ const active = _dirtyMatch(el, dirty);
439
+ cls
440
+ .split(/\s+/)
441
+ .filter(Boolean)
442
+ .forEach((c) => el.classList.toggle(c, !active));
443
+ });
444
+ _ownedEls(comp, "[flow\\:dirty\\.attr]").forEach((el) => {
445
+ const attr = el.getAttribute("flow:dirty.attr");
446
+ if (!attr) return;
447
+ if (_dirtyMatch(el, dirty)) el.setAttribute(attr, "");
448
+ else el.removeAttribute(attr);
449
+ });
450
+ }
451
+
452
+ let _isOnline = true;
453
+
454
+ function _setConnectionState(online: boolean): void {
455
+ if (_isOnline === online) return;
456
+ _isOnline = online;
457
+ document.body.setAttribute("data-flow-connection", online ? "online" : "offline");
458
+ document.dispatchEvent(new CustomEvent(online ? "flow:online" : "flow:offline"));
459
+
460
+ const offline = !online;
461
+ document.querySelectorAll("[flow\\:offline\\.class]").forEach((el) => {
462
+ const cls = el.getAttribute("flow:offline.class");
463
+ if (cls)
464
+ cls
465
+ .split(/\s+/)
466
+ .filter(Boolean)
467
+ .forEach((c) => el.classList.toggle(c, offline));
468
+ });
469
+ document.querySelectorAll("[flow\\:offline\\.class\\.remove]").forEach((el) => {
470
+ const cls = el.getAttribute("flow:offline.class.remove");
471
+ if (cls)
472
+ cls
473
+ .split(/\s+/)
474
+ .filter(Boolean)
475
+ .forEach((c) => el.classList.toggle(c, !offline));
476
+ });
477
+ document.querySelectorAll("[flow\\:offline\\.attr]").forEach((el) => {
478
+ const attr = el.getAttribute("flow:offline.attr");
479
+ if (!attr) return;
480
+ if (offline) el.setAttribute(attr, "");
481
+ else el.removeAttribute(attr);
482
+ });
483
+ }
484
+
485
+ let _ws: WebSocket | null = null;
486
+ let _reconnectDelay = 1_000;
487
+ const MAX_DELAY = 30_000;
488
+
489
+ // ── HTTP fallback (circuit breaker) ─────────────────────────────────────────────
490
+ // Strict corporate proxies/firewalls often block WebSocket upgrades entirely. After a few failed
491
+ // handshakes we fall back to sending action frames over plain HTTP POST (/__flow/http), which runs
492
+ // the identical server pipeline and returns the frames to apply. WS reconnection keeps running in
493
+ // the background, so if the socket becomes available again we upgrade back automatically.
494
+ let _httpMode = false;
495
+ let _wsFailures = 0;
496
+ const HTTP_FALLBACK_AFTER = 3; // consecutive failed WS handshakes before switching to HTTP
497
+
498
+ function _enableHttpMode(): void {
499
+ if (_httpMode) return;
500
+ _httpMode = true;
501
+ console.warn("[Flow] WebSocket unavailable — falling back to HTTP requests.");
502
+ _setConnectionState(true); // HTTP works, so we're "online" for offline directives
503
+ // The socket that dropped may never come back, so this is the reconnect for anything it
504
+ // left mid-action — resync over HTTP before replaying, same order as the WS path.
505
+ _resyncStaleComponents();
506
+ // Drain anything queued while we were deciding, now over HTTP.
507
+ const replay = [..._offlineQueues.values()].flat();
508
+ _offlineQueues.clear();
509
+ for (const a of replay) _sendCall(a.comp, a.method, a.args, a.updates);
510
+ }
511
+
512
+ /** POST an action frame to the HTTP fallback endpoint and apply the returned frames. */
513
+ function _httpSend(frame: unknown, compId: string): void {
514
+ fetch("/__flow/http", {
515
+ method: "POST",
516
+ headers: { "Content-Type": "application/json" },
517
+ credentials: "include",
518
+ body: JSON.stringify(frame),
519
+ })
520
+ .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
521
+ .then((frames: unknown) => {
522
+ if (Array.isArray(frames)) for (const f of frames) void _handleServerFrame(f as ServerFrame);
523
+ })
524
+ .catch((e) => {
525
+ console.error("[Flow] HTTP action failed:", e);
526
+ _resolveAck(compId); // never wedge the per-component queue
527
+ });
528
+ }
529
+
530
+ const _queues = new Map<string, Promise<void>>();
531
+
532
+ // Offline queue: multiple actions per component (FIFO, sent on reconnect)
533
+ type OfflineAction = {
534
+ comp: FlowComponent;
535
+ method: string;
536
+ args: unknown[];
537
+ updates: Record<string, unknown>;
538
+ };
539
+ const _offlineQueues = new Map<string, OfflineAction[]>();
540
+
541
+ const _intervals = new Map<string, ReturnType<typeof setInterval>[]>();
542
+ const _observers = new Map<string, IntersectionObserver[]>();
543
+ const _lazyObservers = new Map<string, IntersectionObserver>();
544
+
545
+ function _pendingUpdates(comp: FlowComponent): Record<string, unknown> {
546
+ const updates: Record<string, unknown> = {};
547
+ for (const [key, val] of Object.entries(comp.ephemeral)) {
548
+ if (JSON.stringify(comp.canonical[key]) !== JSON.stringify(val)) {
549
+ updates[key] = val;
550
+ }
551
+ }
552
+ return updates;
553
+ }
554
+
555
+ // Serialize round-trips per component: only ONE action is in flight for a component at a
556
+ // time. Snapshot deltas are order-dependent — the server diffs the new state against the
557
+ // snapshot the client SENT, so the client must apply the returned delta to that same base.
558
+ // Pipelining (sending the next action before the previous patch lands) would apply a delta
559
+ // to an already-updated snapshot and corrupt it, failing the HMAC on the next action. Each
560
+ // queued send therefore resolves only when its patch/terminal frame is applied (or a timeout
561
+ // fires), which gates the next send until comp.snapshot reflects the previous patch.
562
+ const _pendingAcks = new Map<string, () => void>();
563
+ const ACK_TIMEOUT_MS = 15_000;
564
+
565
+ /**
566
+ * Components whose last action was released without its patch ever being applied — the
567
+ * socket closed, or the ack timed out, while the action was in flight.
568
+ *
569
+ * The client cannot tell whether such an action ran. The server is stateless per frame, so
570
+ * a completed action's database write is committed while the client still holds the
571
+ * pre-action snapshot: the in-memory half is silently reverted, nothing is shown, and the
572
+ * next action then builds on state that does not match the server's. Rather than guess,
573
+ * these components are re-synchronised from the server on reconnect — the server's current
574
+ * state is the truth whether the action ran or not. See {@link _resyncStaleComponents}.
575
+ */
576
+ const _staleComponents = new Set<string>();
577
+
578
+ function _resolveAck(compId: string): void {
579
+ const resolve = _pendingAcks.get(compId);
580
+ if (resolve) {
581
+ _pendingAcks.delete(compId);
582
+ resolve();
583
+ }
584
+ }
585
+
586
+ /**
587
+ * Release an ack whose patch never arrived, and remember that the component's state is now
588
+ * of unknown accuracy.
589
+ *
590
+ * @param compId - The component whose in-flight action was abandoned.
591
+ */
592
+ function _abandonAck(compId: string): void {
593
+ if (!_pendingAcks.has(compId)) return;
594
+ _staleComponents.add(compId);
595
+ _resolveAck(compId);
596
+ }
597
+
598
+ /**
599
+ * Ask the server to re-derive the state of every component left mid-action by a dropped
600
+ * connection or a timed-out ack.
601
+ *
602
+ * `$refresh` re-runs `onMount()` server-side and sends back a patch built from that, so a
603
+ * component whose state comes from the database converges on what was actually committed —
604
+ * including the effects of an action whose acknowledgement was lost. A `flow:desync` event
605
+ * is dispatched first so an app can say something about it rather than having the UI change
606
+ * under the user with no explanation.
607
+ */
608
+ function _resyncStaleComponents(refresh: (id: string) => boolean = _refreshMounted): string[] {
609
+ if (_staleComponents.size === 0) return [];
610
+ const ids = [..._staleComponents];
611
+ // Cleared before refreshing, not after: `$refresh` goes through the send queue and can
612
+ // itself be abandoned, which must re-mark the component rather than be wiped by this
613
+ // drain finishing.
614
+ _staleComponents.clear();
615
+
616
+ const resynced = ids.filter((id) => refresh(id));
617
+
618
+ if (resynced.length > 0 && typeof document !== "undefined") {
619
+ document.dispatchEvent(new CustomEvent("flow:desync", { detail: { components: resynced } }));
620
+ }
621
+ return resynced;
622
+ }
623
+
624
+ /** Send `$refresh` to a mounted component. Returns false when it is no longer on the page. */
625
+ function _refreshMounted(id: string): boolean {
626
+ const comp = _components.get(id);
627
+ if (!comp) return false;
628
+ _callAction(comp, "$refresh", []);
629
+ return true;
630
+ }
631
+
632
+ /**
633
+ * @internal Exported for tests: the bridge's stale-component bookkeeping runs in a browser,
634
+ * but the state machine — mark on abandon, drain exactly once, re-mark if the resync itself
635
+ * is abandoned — is what carries the risk and is testable on its own.
636
+ */
637
+ export const _resyncInternals = {
638
+ markStale: (id: string): void => {
639
+ _staleComponents.add(id);
640
+ },
641
+ isStale: (id: string): boolean => _staleComponents.has(id),
642
+ size: (): number => _staleComponents.size,
643
+ clear: (): void => _staleComponents.clear(),
644
+ drain: (refresh: (id: string) => boolean): string[] => _resyncStaleComponents(refresh),
645
+ };
646
+
647
+ /**
648
+ * Enqueue a call on the component's serial queue. The returned queue promise resolves when
649
+ * the action's patch (or a terminal error frame) is applied — not merely when it is sent —
650
+ * so the next action for this component waits for an up-to-date snapshot base. `fixedUpdates`
651
+ * is supplied for offline replay (captured at queue time); otherwise updates are read at send.
652
+ */
653
+ function _sendCall(
654
+ comp: FlowComponent,
655
+ method: string,
656
+ args: unknown[],
657
+ fixedUpdates?: Record<string, unknown>,
658
+ ): void {
659
+ const compId = comp.id;
660
+ const prev = _queues.get(compId) ?? Promise.resolve();
661
+ _queues.set(
662
+ compId,
663
+ prev
664
+ .then(
665
+ () =>
666
+ new Promise<void>((resolve) => {
667
+ const frame = {
668
+ type: "call",
669
+ component: compId,
670
+ method,
671
+ args,
672
+ updates: fixedUpdates ?? _pendingUpdates(comp),
673
+ snapshot: comp.snapshot,
674
+ };
675
+ // Safety net: never wedge the queue if a patch is dropped (server crash / HTTP error).
676
+ const armSafetyNet = (): void => {
677
+ setTimeout(() => {
678
+ // A timed-out ack carries exactly the same ambiguity as a dropped socket:
679
+ // the action may well have run. Mark the component for resync rather than
680
+ // carrying on from a snapshot that may no longer match the server.
681
+ if (_pendingAcks.get(compId) === resolve) _abandonAck(compId);
682
+ }, ACK_TIMEOUT_MS);
683
+ };
684
+
685
+ if (_ws && _ws.readyState === WebSocket.OPEN) {
686
+ _ws.send(JSON.stringify(frame));
687
+ _pendingAcks.set(compId, resolve);
688
+ armSafetyNet();
689
+ } else if (_httpMode && _isOnline) {
690
+ // HTTP fallback: the returned frames resolve the ack (a patch → _resolveAck).
691
+ _pendingAcks.set(compId, resolve);
692
+ _httpSend(frame, compId);
693
+ armSafetyNet();
694
+ } else {
695
+ const q = _offlineQueues.get(compId) ?? [];
696
+ q.push({ comp, method, args, updates: fixedUpdates ?? _pendingUpdates(comp) });
697
+ _offlineQueues.set(compId, q);
698
+ resolve();
699
+ }
700
+ }),
701
+ )
702
+ .catch((e) => {
703
+ console.error(e);
704
+ _resolveAck(compId);
705
+ }),
706
+ );
707
+ }
708
+
709
+ function _dispatchFrame(comp: FlowComponent, method: string, args: unknown[]): void {
710
+ // Fast offline path when the socket is down AND we're not in HTTP fallback; otherwise _sendCall
711
+ // routes over the socket or HTTP as appropriate (and queues offline itself if truly offline).
712
+ if (!_httpMode && (!_isOnline || !_ws || _ws.readyState !== WebSocket.OPEN)) {
713
+ const q = _offlineQueues.get(comp.id) ?? [];
714
+ q.push({ comp, method, args, updates: _pendingUpdates(comp) });
715
+ _offlineQueues.set(comp.id, q);
716
+ return;
717
+ }
718
+ _sendCall(comp, method, args);
719
+ }
720
+
721
+ function _connect(): void {
722
+ const proto = location.protocol === "https:" ? "wss:" : "ws:";
723
+ _ws = new WebSocket(`${proto}//${location.host}/__flow/ws`);
724
+
725
+ _ws.onopen = () => {
726
+ _reconnectDelay = 1_000;
727
+ _wsFailures = 0;
728
+ if (_httpMode) {
729
+ _httpMode = false; // socket is back — upgrade out of HTTP fallback
730
+ console.info("[Flow] WebSocket restored — leaving HTTP fallback.");
731
+ }
732
+ _setConnectionState(true);
733
+
734
+ // Replay all queued offline actions in order — through the serial queue, so a
735
+ // component's actions still go out one-at-a-time (deltas stay aligned to their base).
736
+ // Resync before replaying: a component left mid-action needs the server's current
737
+ // state as its base, or the replayed actions build on a snapshot that may already be
738
+ // out of date. Both go through the per-component serial queue, so the order holds.
739
+ _resyncStaleComponents();
740
+
741
+ const replay = [..._offlineQueues.values()].flat();
742
+ _offlineQueues.clear();
743
+ for (const action of replay) {
744
+ _sendCall(action.comp, action.method, action.args, action.updates);
745
+ }
746
+ };
747
+
748
+ _ws.onmessage = (e: MessageEvent) => {
749
+ let frame: ServerFrame & { type: string; scripts?: string[] };
750
+ try {
751
+ frame = JSON.parse(e.data as string) as typeof frame;
752
+ } catch {
753
+ return;
754
+ }
755
+ void _handleServerFrame(frame);
756
+ };
757
+
758
+ _ws.onclose = () => {
759
+ _ws = null;
760
+
761
+ // Release any in-flight ack waiters and reset the per-component queues so a fresh
762
+ // action chain starts cleanly on reconnect. Each released ack belonged to an action
763
+ // that may have completed server-side, so its component is flagged for resync rather
764
+ // than left silently holding pre-action state.
765
+ for (const id of [..._pendingAcks.keys()]) _abandonAck(id);
766
+ for (const [id] of _queues) _queues.set(id, Promise.resolve());
767
+
768
+ _components.forEach((c) => _setLoading(c, false));
769
+
770
+ // Trip the circuit breaker after a few failed handshakes — switch to HTTP so the app keeps
771
+ // working where WebSockets are blocked. WS reconnection continues in the background.
772
+ _wsFailures++;
773
+ if (!_httpMode && _wsFailures >= HTTP_FALLBACK_AFTER) _enableHttpMode();
774
+ if (!_httpMode) _setConnectionState(false);
775
+
776
+ setTimeout(() => {
777
+ _reconnectDelay = Math.min(_reconnectDelay * 2, MAX_DELAY);
778
+ _connect();
779
+ }, _reconnectDelay);
780
+ };
781
+ }
782
+
783
+ type AlpineWithEval = AlpineType & {
784
+ evaluate(el: Element, expression: string): unknown;
785
+ };
786
+
787
+ function _executeClientScripts(comp: FlowComponent, scripts: string[]): void {
788
+ const A = Alpine as unknown as AlpineWithEval;
789
+ for (const script of scripts) {
790
+ try {
791
+ A.evaluate(comp.rootEl, script);
792
+ } catch (e) {
793
+ console.error("[Flow] client() script error:", e, "\nScript:", script);
794
+ }
795
+ }
796
+ }
797
+
798
+ // ── Per-component error store ─────────────────────────────────────────────────
799
+
800
+ const _errors = new Map<string, Record<string, string[]>>();
801
+
802
+ // ── $flow.$watch subscriptions ───────────────────────────────────────────────
803
+
804
+ type WatchFn = (newVal: unknown, oldVal: unknown) => void;
805
+ const _watchers = new Map<string, Map<string, Set<WatchFn>>>();
806
+
807
+ function _runWatchers(comp: FlowComponent, oldValues: Record<string, unknown>): void {
808
+ const compMap = _watchers.get(comp.id);
809
+ if (!compMap) return;
810
+ for (const [prop, fns] of compMap) {
811
+ const next = comp.reactive[prop];
812
+ if (JSON.stringify(oldValues[prop]) !== JSON.stringify(next)) {
813
+ for (const fn of fns) {
814
+ try {
815
+ fn(next, oldValues[prop]);
816
+ } catch (e) {
817
+ console.error("[Flow] $watch error:", e);
818
+ }
819
+ }
820
+ }
821
+ }
822
+ }
823
+
824
+ function _setErrors(comp: FlowComponent, errors: Record<string, string[]> | undefined): void {
825
+ if (errors && Object.keys(errors).length > 0) {
826
+ _errors.set(comp.id, errors);
827
+ } else {
828
+ _errors.delete(comp.id);
829
+ }
830
+ }
831
+
832
+ function _getErrors(compId: string): Record<string, string[]> {
833
+ return _errors.get(compId) ?? {};
834
+ }
835
+
836
+ // ── @url sync ─────────────────────────────────────────────────────────────────
837
+
838
+ /**
839
+ * After a patch, update the browser URL query string for @url-decorated props.
840
+ * The metadata lives in snapshot.data[key][1].url.
841
+ */
842
+ function _syncUrlParams(comp: FlowComponent): void {
843
+ const data = comp.snapshot.data;
844
+ let pushed = false;
845
+ const params = new URLSearchParams(location.search);
846
+
847
+ for (const [key, [, meta]] of Object.entries(data)) {
848
+ const urlMeta = meta["url"] as { as: string; history: "push" | "replace" } | undefined;
849
+ if (!urlMeta) continue;
850
+
851
+ const paramName = urlMeta.as ?? key;
852
+ const val = comp.reactive[key];
853
+
854
+ if (val === null || val === undefined || val === "") {
855
+ params.delete(paramName);
856
+ } else {
857
+ params.set(paramName, String(val));
858
+ }
859
+
860
+ if (urlMeta.history === "push" && !pushed) pushed = true;
861
+ }
862
+
863
+ const newSearch = params.toString();
864
+ const newUrl = `${location.pathname}${newSearch ? "?" + newSearch : ""}${location.hash}`;
865
+
866
+ if (newUrl !== location.pathname + location.search + location.hash) {
867
+ if (pushed) {
868
+ history.pushState({ flowNavigate: true }, "", newUrl);
869
+ } else {
870
+ history.replaceState(history.state, "", newUrl);
871
+ }
872
+ }
873
+ }
874
+
875
+ // ── flow:transition ──────────────────────────────────────────────────────────
876
+
877
+ /** Trigger the enter transition for elements with flow:transition. */
878
+ function _triggerEnterTransition(el: Element): void {
879
+ if (!(el instanceof HTMLElement)) return;
880
+ el.classList.add("flow-entering");
881
+ requestAnimationFrame(() => {
882
+ requestAnimationFrame(() => {
883
+ el.classList.remove("flow-entering");
884
+ });
885
+ });
886
+ }
887
+
888
+ // ── flow:teleport ────────────────────────────────────────────────────────────
889
+
890
+ /**
891
+ * Process `flow:teleport` elements: move them to their target location in the
892
+ * DOM. The attribute value is a CSS selector for the target parent.
893
+ *
894
+ * @example
895
+ * <div flow:teleport="#modal-root">...</div>
896
+ */
897
+ function _processTeleports(): void {
898
+ document.querySelectorAll("[flow\\:teleport]").forEach((el) => {
899
+ const target = el.getAttribute("flow:teleport");
900
+ if (!target) return;
901
+ const dest = document.querySelector(target);
902
+ if (!dest || el.parentElement === dest) return;
903
+
904
+ // Save the owning component id so findComponentByEl works after the move
905
+ const owner = findComponentByEl(el);
906
+ if (owner) (el as HTMLElement).dataset["flowOwner"] = owner.id;
907
+
908
+ // Dedupe: a parent re-render morphs a FRESH copy of this element back into the
909
+ // root; remove the previously-teleported copy (matched by id) before moving the
910
+ // new one, so the destination never accumulates duplicates. Give teleported
911
+ // elements a stable `id` for this to work across re-renders.
912
+ const id = (el as HTMLElement).id;
913
+ if (id) {
914
+ const prev = dest.querySelector(`#${CSS.escape(id)}[data-flow-teleported]`);
915
+ if (prev && prev !== el) prev.remove();
916
+ }
917
+
918
+ // Move the element to the target, keeping original position marker
919
+ const marker = document.createComment(`flow:teleport ${id || "unknown"}`);
920
+ el.replaceWith(marker);
921
+ dest.appendChild(el);
922
+ // Mark as teleported so we don't re-process
923
+ (el as HTMLElement).dataset["flowTeleported"] = "1";
924
+ });
925
+ }
926
+
927
+ // ── Cross-component events ────────────────────────────────────────────────────
928
+
929
+ /**
930
+ * Emit a cross-component event: broadcast it on window (so Alpine
931
+ * `x-on:event-name.window` works) AND trigger any server `@on` listeners on
932
+ * matching components. Used by both server-dispatched event frames and the
933
+ * client-side $dispatch / $dispatchTo / $dispatchSelf magics, so a client
934
+ * dispatch reaches server listeners exactly like a server dispatch does.
935
+ *
936
+ * opts.toName → deliver only to components whose class name matches
937
+ * opts.selfId → deliver only to the component with this id
938
+ */
939
+ function _emitEvent(
940
+ name: string,
941
+ data: Record<string, unknown>,
942
+ opts: { toName?: string | undefined; selfId?: string | undefined } = {},
943
+ ): void {
944
+ window.dispatchEvent(new CustomEvent(`flow:${name}`, { detail: data, bubbles: true }));
945
+
946
+ for (const [, comp] of _components) {
947
+ if (opts.selfId && comp.id !== opts.selfId) continue;
948
+ if (opts.toName && comp.snapshot.memo.name !== opts.toName) continue;
949
+ const method = comp.snapshot.memo.listeners?.[name];
950
+ if (method) _callAction(comp, method, [data]);
951
+ }
952
+ }
953
+
954
+ /** Server-dispatched event frame → emit, honouring optional to/self targeting. */
955
+ function _handleEventFrame(
956
+ name: string,
957
+ data: Record<string, unknown>,
958
+ to?: string,
959
+ selfId?: string,
960
+ ): void {
961
+ _emitEvent(name, data, { toName: to, selfId });
962
+ }
963
+
964
+ // ── File downloads ────────────────────────────────────────────────────────────
965
+
966
+ function _handleDownloadFrame(filename: string, content: string, mime: string): void {
967
+ try {
968
+ const binary = atob(content);
969
+ const bytes = new Uint8Array(binary.length);
970
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
971
+ const blob = new Blob([bytes], { type: mime });
972
+ const url = URL.createObjectURL(blob);
973
+ const a = document.createElement("a");
974
+ a.href = url;
975
+ a.download = filename;
976
+ a.style.display = "none";
977
+ document.body.appendChild(a);
978
+ a.click();
979
+ document.body.removeChild(a);
980
+ setTimeout(() => URL.revokeObjectURL(url), 60_000);
981
+ } catch (e) {
982
+ console.error("[Flow] download error:", e);
983
+ }
984
+ }
985
+
986
+ // ── Flash notifications ───────────────────────────────────────────────────────
987
+
988
+ function _dispatchFlash(detail: Record<string, unknown>): void {
989
+ window.dispatchEvent(new CustomEvent("flow:flash", { detail }));
990
+ }
991
+
992
+ // ── Server frame handler ──────────────────────────────────────────────────────
993
+
994
+ // One-shot guard for the auto-reload that recovers from an expired/unknown component
995
+ // (see the "error" frame handling). Persisted across the reload via sessionStorage so the
996
+ // reload itself can't re-arm it, and cleared once normal patches resume — so each broken
997
+ // episode triggers at most one reload, while a later, genuine restart can recover again.
998
+ const _EXPIRY_RELOAD_KEY = "flow:reloaded-for-expired-component";
999
+
1000
+ function _reloadedForExpiry(): boolean {
1001
+ try {
1002
+ return sessionStorage.getItem(_EXPIRY_RELOAD_KEY) === "1";
1003
+ } catch {
1004
+ return false;
1005
+ }
1006
+ }
1007
+
1008
+ function _markReloadedForExpiry(): void {
1009
+ try {
1010
+ sessionStorage.setItem(_EXPIRY_RELOAD_KEY, "1");
1011
+ } catch {
1012
+ // sessionStorage unavailable (private mode, etc.) — proceed without the guard.
1013
+ }
1014
+ }
1015
+
1016
+ function _clearExpiryReloadGuard(): void {
1017
+ try {
1018
+ sessionStorage.removeItem(_EXPIRY_RELOAD_KEY);
1019
+ } catch {
1020
+ // ignore
1021
+ }
1022
+ }
1023
+
1024
+ /**
1025
+ * Morph a component's DOM to fresh HTML (the shared body of a patch apply and a time-travel
1026
+ * jump), then re-scan/refresh so restored islands and directives are interactive again.
1027
+ */
1028
+ function _morphComponent(comp: FlowComponent, html: string): void {
1029
+ try {
1030
+ Alpine.morph(comp.rootEl, html, {
1031
+ key: (el: Element) =>
1032
+ (el as HTMLElement).dataset?.["flowId"] ??
1033
+ el.getAttribute("flow:id") ??
1034
+ el.getAttribute("flow:key") ??
1035
+ (el as HTMLElement).id ??
1036
+ null,
1037
+
1038
+ removing: (el: Node, skip: () => void) => {
1039
+ if (el instanceof Element && el.tagName === "SCRIPT" && el.id.startsWith("flow-state-")) {
1040
+ skip();
1041
+ }
1042
+ },
1043
+
1044
+ updating: (from: Node, to: Node, childrenOnly: () => void, skip: () => void) => {
1045
+ if (!(from instanceof Element)) return;
1046
+
1047
+ if (from.hasAttribute("flow:ignore")) {
1048
+ skip();
1049
+ return;
1050
+ }
1051
+ if (from.hasAttribute("flow:ignore.self")) {
1052
+ childrenOnly();
1053
+ return;
1054
+ }
1055
+
1056
+ if (from.hasAttribute("flow:replace") && to instanceof Element) {
1057
+ from.innerHTML = to.innerHTML;
1058
+ skip();
1059
+ return;
1060
+ }
1061
+
1062
+ if (
1063
+ from.hasAttribute("data-flow-root") &&
1064
+ (from as HTMLElement).dataset["flowId"] !== comp.id
1065
+ ) {
1066
+ // Deliver parent→child reactive/modelable bindings before preserving the island.
1067
+ if (to instanceof Element) _applyChildBindings(from, to);
1068
+ skip();
1069
+ }
1070
+ },
1071
+
1072
+ added: (el: Node) => {
1073
+ if (el instanceof Element && el.hasAttribute("flow:transition")) {
1074
+ _triggerEnterTransition(el);
1075
+ }
1076
+ },
1077
+ });
1078
+ } catch (e) {
1079
+ console.error("[Flow] morph error:", e);
1080
+ }
1081
+
1082
+ _scanComponents();
1083
+ _cleanupDisconnected();
1084
+ _refreshComponentFeatures(comp);
1085
+ }
1086
+
1087
+ // ── Time-travel devtools (dev-only) ────────────────────────────────────────────
1088
+ // Enabled when the server's `ready` frame reports `dev: true`. Recording lives in the patch
1089
+ // handler and _scanComponents; this wires the DOM side — applying a jumped-to frame, a console
1090
+ // API (window.__flow.timeline), and a minimal scrubbing panel.
1091
+
1092
+ /** Restore a recorded frame's snapshot + HTML into the live component (client-only). */
1093
+ function _applyTimelineFrame(frame: _timeline.TimelineFrame): void {
1094
+ const comp = _components.get(frame.compId);
1095
+ if (!comp) return;
1096
+ comp.mergeServerPatch(frame.snapshot); // reactive state + <script id="flow-state-*"> ← this frame
1097
+ _syncFlowStore(comp);
1098
+ if (frame.html) _morphComponent(comp, frame.html);
1099
+ _syncModelInputs(comp);
1100
+ _syncDeclarative(comp);
1101
+ }
1102
+
1103
+ function _enableTimeline(): void {
1104
+ setTimelineEnabled(true);
1105
+ setTimelineApplier(_applyTimelineFrame);
1106
+ // Seed the initial (mount) frame for every component already scanned before the WS was ready.
1107
+ for (const [id, comp] of _components) {
1108
+ _recordTimelineFrame({
1109
+ compId: id,
1110
+ compName: comp.name,
1111
+ action: "mount",
1112
+ snapshot: comp.snapshot,
1113
+ html: comp.rootEl.outerHTML,
1114
+ });
1115
+ }
1116
+ const w = window as unknown as {
1117
+ __flow?: Record<string, unknown>;
1118
+ __zerotalDevtools?: _DevtoolsRegistryLike;
1119
+ };
1120
+ w.__flow = {
1121
+ ...(w.__flow ?? {}),
1122
+ timeline: {
1123
+ frames: () => _timeline.getFrames(),
1124
+ framesFor: (id: string) => _timeline.getFramesFor(id),
1125
+ jump: (seq: number) => _timeline.jumpTo(seq),
1126
+ live: (id: string) => _timeline.resumeLive(id),
1127
+ current: (id: string) => _timeline.currentSeq(id),
1128
+ },
1129
+ };
1130
+
1131
+ // Prefer the unified Zerotal devtools panel: register a "Timeline" tab there. Only fall back to
1132
+ // the standalone pill when @zerotal/devtools isn't present on the page.
1133
+ const dt = w.__zerotalDevtools;
1134
+ if (dt && typeof dt.register === "function") {
1135
+ dt.register({
1136
+ id: "flow-timeline",
1137
+ title: "Timeline",
1138
+ badge: () => _timeline.getFrames().length || undefined,
1139
+ render: (el: HTMLElement) => _renderTimelineInto(el),
1140
+ });
1141
+ _timeline.onTimelineChange(() => dt.refresh("flow-timeline"));
1142
+ } else {
1143
+ _setupTimelinePanel();
1144
+ }
1145
+ }
1146
+
1147
+ /** Minimal shape of the devtools extension registry (window.__zerotalDevtools) we depend on. */
1148
+ interface _DevtoolsRegistryLike {
1149
+ register(panel: {
1150
+ id: string;
1151
+ title: string;
1152
+ badge?: () => number | string | undefined;
1153
+ render: (el: HTMLElement) => void;
1154
+ }): unknown;
1155
+ refresh(id?: string): void;
1156
+ }
1157
+
1158
+ /** Render the timeline into the Zerotal devtools content area (themed with its CSS vars/classes). */
1159
+ function _renderTimelineInto(el: HTMLElement): void {
1160
+ const frames = _timeline.getFrames();
1161
+ const compIds = [...new Set(frames.map((f) => f.compId))];
1162
+ const curSeqs = new Set(compIds.map((id) => _timeline.currentSeq(id)));
1163
+ const rewound = compIds.some((id) => !_timeline.isLive(id));
1164
+
1165
+ const header =
1166
+ `<div class="sec" style="display:flex;align-items:center;gap:8px">` +
1167
+ `<div class="stitle" style="margin:0;flex:1">Time-travel · ${frames.length} frame${frames.length === 1 ? "" : "s"}</div>` +
1168
+ (rewound ? `<button class="ibtn live-on" data-tl-live="1">⏵ Resume live</button>` : "") +
1169
+ `</div>`;
1170
+
1171
+ const rows = frames.length
1172
+ ? frames
1173
+ .slice()
1174
+ .reverse()
1175
+ .map((f) => {
1176
+ const cur = curSeqs.has(f.seq);
1177
+ const changed = f.changed.length ? f.changed.join(", ") : "—";
1178
+ const time = new Date(f.ts).toLocaleTimeString();
1179
+ return (
1180
+ `<div class="qrow" data-tl-seq="${f.seq}" style="cursor:pointer;display:flex;gap:8px;align-items:baseline${cur ? ";background:var(--card)" : ""}">` +
1181
+ `<span class="dim" style="min-width:30px">#${f.seq}</span>` +
1182
+ `<span style="color:var(--purple);font-weight:700">${_escapeHtml(f.action)}</span>` +
1183
+ `<span class="dim" style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${_escapeHtml(f.compName)} · ${_escapeHtml(changed)}</span>` +
1184
+ `<span class="dim" style="font-size:10px">${time}</span>` +
1185
+ `</div>`
1186
+ );
1187
+ })
1188
+ .join("")
1189
+ : '<p class="empty">No frames yet — interact with a component.</p>';
1190
+
1191
+ el.innerHTML = header + `<div>${rows}</div>`;
1192
+ // A single delegated handler (assignment replaces any prior one — no stacking across renders).
1193
+ el.onclick = (e: MouseEvent) => {
1194
+ const target = e.target as HTMLElement;
1195
+ if (target.closest("[data-tl-live]")) {
1196
+ for (const id of new Set(_timeline.getFrames().map((f) => f.compId)))
1197
+ _timeline.resumeLive(id);
1198
+ return;
1199
+ }
1200
+ const row = target.closest<HTMLElement>("[data-tl-seq]");
1201
+ if (row?.dataset["tlSeq"]) _timeline.jumpTo(Number(row.dataset["tlSeq"]));
1202
+ };
1203
+ }
1204
+
1205
+ // A compact, dev-only scrubbing panel docked bottom-left. The console API above is the primary
1206
+ // interface; this is a convenience over it.
1207
+ function _setupTimelinePanel(): void {
1208
+ if (document.getElementById("flow-tl-panel")) return;
1209
+ const style = document.createElement("style");
1210
+ style.textContent = `
1211
+ #flow-tl-panel{position:fixed;left:10px;top:88px;z-index:2147483647;font:12px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace;color:#e5e7eb}
1212
+ #flow-tl-panel *{box-sizing:border-box}
1213
+ .flow-tl-toggle{background:#111827;color:#e5e7eb;border:1px solid #374151;border-radius:9999px;padding:5px 10px;cursor:pointer;box-shadow:0 4px 12px rgba(0,0,0,.3)}
1214
+ .flow-tl-body{margin-top:6px;width:320px;max-height:50vh;display:flex;flex-direction:column;background:#0b1220;border:1px solid #374151;border-radius:10px;overflow:hidden;box-shadow:0 8px 28px rgba(0,0,0,.45)}
1215
+ .flow-tl-head{display:flex;align-items:center;gap:8px;padding:8px 10px;border-bottom:1px solid #1f2937;font-weight:600}
1216
+ .flow-tl-head .sp{flex:1}
1217
+ .flow-tl-live{background:#059669;color:#fff;border:0;border-radius:6px;padding:3px 8px;cursor:pointer}
1218
+ .flow-tl-list{list-style:none;margin:0;padding:4px;overflow-y:auto}
1219
+ .flow-tl-row{display:flex;gap:8px;align-items:baseline;padding:5px 7px;border-radius:6px;cursor:pointer}
1220
+ .flow-tl-row:hover{background:#111a2e}
1221
+ .flow-tl-row.cur{background:#1d4ed8;color:#fff}
1222
+ .flow-tl-seq{color:#64748b;min-width:26px}
1223
+ .flow-tl-act{font-weight:600;color:#93c5fd}
1224
+ .flow-tl-row.cur .flow-tl-act{color:#fff}
1225
+ .flow-tl-meta{flex:1;color:#94a3b8;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1226
+ .flow-tl-row.cur .flow-tl-meta{color:#dbeafe}
1227
+ `;
1228
+ document.head.appendChild(style);
1229
+
1230
+ const root = document.createElement("div");
1231
+ root.id = "flow-tl-panel";
1232
+ root.innerHTML =
1233
+ `<button class="flow-tl-toggle" type="button">⏱ <b class="flow-tl-count">0</b></button>` +
1234
+ `<div class="flow-tl-body" hidden>` +
1235
+ `<div class="flow-tl-head"><span>Flow timeline</span><span class="sp"></span>` +
1236
+ `<button class="flow-tl-live" type="button" hidden>⏵ Live</button></div>` +
1237
+ `<ol class="flow-tl-list"></ol></div>`;
1238
+ document.body.appendChild(root);
1239
+
1240
+ const toggle = root.querySelector<HTMLButtonElement>(".flow-tl-toggle")!;
1241
+ const body = root.querySelector<HTMLDivElement>(".flow-tl-body")!;
1242
+ const list = root.querySelector<HTMLOListElement>(".flow-tl-list")!;
1243
+ const liveBtn = root.querySelector<HTMLButtonElement>(".flow-tl-live")!;
1244
+
1245
+ toggle.addEventListener("click", () => {
1246
+ body.hidden = !body.hidden;
1247
+ if (!body.hidden) _renderTimelinePanel();
1248
+ });
1249
+ list.addEventListener("click", (e) => {
1250
+ const row = (e.target as Element).closest<HTMLElement>(".flow-tl-row");
1251
+ if (row?.dataset["seq"]) _timeline.jumpTo(Number(row.dataset["seq"]));
1252
+ });
1253
+ liveBtn.addEventListener("click", () => {
1254
+ // Resume the most-recently-rewound component (or all).
1255
+ for (const id of new Set(_timeline.getFrames().map((f) => f.compId))) _timeline.resumeLive(id);
1256
+ });
1257
+
1258
+ _timeline.onTimelineChange(() => _renderTimelinePanel());
1259
+ _renderTimelinePanel();
1260
+ }
1261
+
1262
+ function _renderTimelinePanel(): void {
1263
+ const root = document.getElementById("flow-tl-panel");
1264
+ if (!root) return;
1265
+ const frames = _timeline.getFrames();
1266
+ root.querySelector(".flow-tl-count")!.textContent = String(frames.length);
1267
+
1268
+ const body = root.querySelector<HTMLDivElement>(".flow-tl-body")!;
1269
+ if (body.hidden) return; // don't rebuild the (hidden) list on every frame
1270
+
1271
+ // Rewound if any component's current seq isn't its latest.
1272
+ const rewound = [...new Set(frames.map((f) => f.compId))].some((id) => !_timeline.isLive(id));
1273
+ root.querySelector<HTMLButtonElement>(".flow-tl-live")!.hidden = !rewound;
1274
+
1275
+ const list = root.querySelector<HTMLOListElement>(".flow-tl-list")!;
1276
+ const curSeqs = new Set(
1277
+ [...new Set(frames.map((f) => f.compId))].map((id) => _timeline.currentSeq(id)),
1278
+ );
1279
+ list.innerHTML = frames
1280
+ .slice()
1281
+ .reverse()
1282
+ .map((f) => {
1283
+ const cur = curSeqs.has(f.seq) ? " cur" : "";
1284
+ const t = new Date(f.ts).toLocaleTimeString();
1285
+ const changed = f.changed.length ? f.changed.join(", ") : "—";
1286
+ return (
1287
+ `<li class="flow-tl-row${cur}" data-seq="${f.seq}">` +
1288
+ `<span class="flow-tl-seq">#${f.seq}</span>` +
1289
+ `<span class="flow-tl-act">${_escapeHtml(f.action)}</span>` +
1290
+ `<span class="flow-tl-meta">${_escapeHtml(f.compName)} · ${_escapeHtml(changed)} · ${t}</span>` +
1291
+ `</li>`
1292
+ );
1293
+ })
1294
+ .join("");
1295
+ }
1296
+
1297
+ /**
1298
+ * Rebuild a full snapshot from the component's prior snapshot and a patch delta
1299
+ * (mirror of the server's `applySnapshotDelta`). The prior `data` is exactly the base
1300
+ * the server diffed against, so the reconstruction is byte-equal to what the server
1301
+ * signed — the carried `checksum` verifies on the next action. Kept inline (not imported
1302
+ * from dehydrate.ts) so the client bundle stays free of server-side crypto deps.
1303
+ */
1304
+ function _reconstructSnapshot(
1305
+ prev: Snapshot,
1306
+ frame: {
1307
+ memo?: SnapshotMemo;
1308
+ checksum?: string;
1309
+ dataDelta?: SnapshotData;
1310
+ dataRemoved?: string[];
1311
+ },
1312
+ ): Snapshot {
1313
+ const data: SnapshotData = { ...prev.data };
1314
+ for (const k of frame.dataRemoved ?? []) delete data[k];
1315
+ Object.assign(data, frame.dataDelta ?? {});
1316
+ return {
1317
+ data,
1318
+ memo: frame.memo ?? prev.memo,
1319
+ checksum: frame.checksum ?? prev.checksum,
1320
+ };
1321
+ }
1322
+
1323
+ // ── Dev fast refresh ──────────────────────────────────────────────────────────
1324
+ // On a dev server restart the flow WS reconnects and the server re-sends `ready`
1325
+ // with `dev: true`. Rather than a full reload (which loses state), re-render every
1326
+ // mounted component from its held, still-valid snapshot with the newly-compiled code,
1327
+ // and refetch the stylesheet so new classes apply. If an edit is incompatible with the
1328
+ // held snapshot (a render throw / integrity error), fall back to a one-shot full reload.
1329
+
1330
+ let _connectedBefore = false;
1331
+ let _softRefreshUntil = 0;
1332
+
1333
+ /** Re-render every mounted component from its snapshot (new code, state preserved). */
1334
+ function _softRefresh(): void {
1335
+ _softRefreshUntil = Date.now() + 5_000; // window in which an error frame → fallback reload
1336
+ for (const [, comp] of _components) {
1337
+ _dispatchFrame(comp, "$rerender", []); // no loading spinner; serialised like any action
1338
+ }
1339
+ // Refetch stylesheets so a newly-used Tailwind class (rebuilt on the server restart)
1340
+ // applies without a full reload.
1341
+ document.querySelectorAll<HTMLLinkElement>('link[rel="stylesheet"]').forEach((link) => {
1342
+ try {
1343
+ const url = new URL(link.getAttribute("href") ?? link.href, location.href);
1344
+ url.searchParams.set("flow-refresh", Date.now().toString(36));
1345
+ link.setAttribute("href", url.href);
1346
+ } catch {
1347
+ /* leave a malformed href alone */
1348
+ }
1349
+ });
1350
+ }
1351
+
1352
+ async function _handleServerFrame(
1353
+ frame: ServerFrame & {
1354
+ type: string;
1355
+ html?: string;
1356
+ scripts?: string[];
1357
+ errors?: Record<string, string[]>;
1358
+ title?: string;
1359
+ memo?: SnapshotMemo;
1360
+ checksum?: string;
1361
+ dataDelta?: SnapshotData;
1362
+ dataRemoved?: string[];
1363
+ actionError?: boolean;
1364
+ dev?: boolean;
1365
+ },
1366
+ ): Promise<void> {
1367
+ if (frame.type === "ready") {
1368
+ // The server sends `dev: true` from the dev worker — enable time-travel recording (once).
1369
+ if (frame.dev === true && !isTimelineEnabled()) _enableTimeline();
1370
+ // First `ready` is the initial page load (already fresh); a later one is a reconnect
1371
+ // after a dev restart → soft refresh.
1372
+ if (!_connectedBefore) _connectedBefore = true;
1373
+ else if (frame.dev === true) _softRefresh();
1374
+ return;
1375
+ }
1376
+
1377
+ if (frame.type === "patch") {
1378
+ const { component, scripts, errors, title } = frame;
1379
+ const html = frame.html;
1380
+ const comp = _components.get(component);
1381
+ if (!comp) return;
1382
+
1383
+ // Optimistic failed state: the reconciliation below already reverts a rejected write;
1384
+ // this drives the visible `showOnError` region. Cleared on the next successful patch
1385
+ // (and when a new action is dispatched, see _callAction).
1386
+ _setActionError(comp, frame.actionError === true);
1387
+
1388
+ // Snapshot travels as a delta by default (only changed fields); rebuild the full
1389
+ // snapshot from the component's prior copy. A full `snapshot` is honoured if present.
1390
+ const snapshot: Snapshot = frame.snapshot ?? _reconstructSnapshot(comp.snapshot, frame);
1391
+
1392
+ // A patch landed on a live component — normal operation has resumed, so re-arm the
1393
+ // expiry auto-reload for any future server restart.
1394
+ _clearExpiryReloadGuard();
1395
+
1396
+ // A mid-@task streaming patch (partial) applies the DOM/snapshot but keeps the triggering
1397
+ // action's loading state on — only the final patch clears it (below, at the ack release).
1398
+ if (!frame.partial) _setLoading(comp, false);
1399
+
1400
+ // Snapshot old watcher values before the patch so we can diff them
1401
+ const oldWatchValues: Record<string, unknown> = {};
1402
+ const compWatchers = _watchers.get(comp.id);
1403
+ if (compWatchers) {
1404
+ for (const prop of compWatchers.keys()) oldWatchValues[prop] = comp.reactive[prop];
1405
+ }
1406
+
1407
+ // Update title if server sent one
1408
+ if (title) document.title = title;
1409
+
1410
+ // Update validation errors
1411
+ _setErrors(comp, errors);
1412
+
1413
+ comp.mergeServerPatch(snapshot);
1414
+ // Optimistic collections: if this patch resolves the owning action's ack (checked before the
1415
+ // ack is released below), the action is done — drop pending ops and adopt the authoritative
1416
+ // server state (reconcile on success, roll back on failure). Otherwise it's an interim
1417
+ // (broadcast/event) patch — re-apply the pending optimistic ops so they survive it.
1418
+ if (_optOps.has(component)) {
1419
+ if (!frame.partial && _pendingAcks.has(component)) _optOps.delete(component);
1420
+ else _reapplyOptOps(comp);
1421
+ }
1422
+ _syncFlowStore(comp);
1423
+ _runWatchers(comp, oldWatchValues);
1424
+ _syncModelInputs(comp);
1425
+ _updateDirty(comp);
1426
+ _propagateModelable(comp); // child→parent half of a @modelable binding
1427
+
1428
+ if (html) _morphComponent(comp, html);
1429
+
1430
+ _syncDeclarative(comp);
1431
+ _syncUrlParams(comp);
1432
+ _processTeleports();
1433
+
1434
+ // Move focus to the first invalid focusOnError field (no-op if none / user is mid-edit).
1435
+ if (!frame.partial) _focusFirstError(comp);
1436
+
1437
+ if (scripts && scripts.length > 0) {
1438
+ _executeClientScripts(comp, scripts);
1439
+ }
1440
+
1441
+ // Time-travel: record this applied patch as a timeline frame (final patches only — mid-@task
1442
+ // partials would flood the timeline). No-op unless recording is enabled (dev).
1443
+ if (!frame.partial && isTimelineEnabled()) {
1444
+ _recordTimelineFrame({
1445
+ compId: component,
1446
+ compName: comp.name,
1447
+ action: _takeLastAction(component),
1448
+ snapshot: comp.snapshot,
1449
+ html: comp.rootEl.outerHTML,
1450
+ });
1451
+ }
1452
+
1453
+ // Patch applied and comp.snapshot updated — release the queue so the next action for this
1454
+ // component sends against the fresh base. A partial (mid-@task) patch keeps the queue held
1455
+ // and loading on: only the final patch releases the ack, so the task stays "in flight".
1456
+ if (!frame.partial) _resolveAck(component);
1457
+ return;
1458
+ }
1459
+
1460
+ if (frame.type === "stream") {
1461
+ const sel = `[flow\\:stream="${frame.ref.replace(/"/g, '\\"')}"]`;
1462
+ document.querySelectorAll(sel).forEach((el) => {
1463
+ const replace = frame.replace || el.hasAttribute("flow:stream.replace");
1464
+ // Text by default. The canonical use of flow:stream is streaming LLM tokens — the
1465
+ // least trustworthy content a server handles — and parsing it as HTML makes a
1466
+ // prompt-injected `<img src=x onerror=…>` execute with the framework's blessing.
1467
+ // An element that genuinely streams server-authored markup opts in per element with
1468
+ // `flow:stream.html`, which reads as the deliberate choice it is.
1469
+ if (el.hasAttribute("flow:stream.html")) {
1470
+ if (replace) el.innerHTML = frame.content;
1471
+ else el.insertAdjacentHTML("beforeend", frame.content);
1472
+ } else {
1473
+ if (replace) el.textContent = frame.content;
1474
+ else el.append(document.createTextNode(frame.content));
1475
+ }
1476
+ });
1477
+ return;
1478
+ }
1479
+
1480
+ if (frame.type === "flash") {
1481
+ const { type: _t, ...detail } = frame;
1482
+ _dispatchFlash(detail);
1483
+ return;
1484
+ }
1485
+
1486
+ if (frame.type === "redirect") {
1487
+ if (frame.sessionToken) {
1488
+ try {
1489
+ await fetch(`/__flow/session-relay?t=${encodeURIComponent(frame.sessionToken)}`, {
1490
+ credentials: "include",
1491
+ });
1492
+ } catch {
1493
+ // Network error — navigate anyway.
1494
+ }
1495
+ }
1496
+ window.location.href = frame.url;
1497
+ return;
1498
+ }
1499
+
1500
+ if (frame.type === "error") {
1501
+ _components.forEach((c) => _setLoading(c, false));
1502
+ // A rejected action is terminal — release the queue so the component isn't wedged.
1503
+ if (frame.component) _resolveAck(frame.component);
1504
+
1505
+ // Dev fast-refresh fallback: an error during the soft-refresh window means the edit is
1506
+ // incompatible with the held snapshot (a render throw / integrity failure), so a plain
1507
+ // re-render can't recover — do a one-shot full reload to load the new code cleanly.
1508
+ if (Date.now() < _softRefreshUntil && !_reloadedForExpiry()) {
1509
+ _softRefreshUntil = 0;
1510
+ _markReloadedForExpiry();
1511
+ console.warn("[Flow] Fast refresh couldn't re-render from the held state — reloading.");
1512
+ window.location.reload();
1513
+ return;
1514
+ }
1515
+
1516
+ // "Unknown component" means the server no longer has this component's server-side
1517
+ // registration — almost always because the server restarted (dev hot-reload) or the
1518
+ // page's registration expired. The client snapshot is now orphaned, so no targeted
1519
+ // patch can ever succeed and the page is silently dead. Recover by reloading: the
1520
+ // fresh document re-renders and re-registers every component server-side, then
1521
+ // re-hydrates the client. A one-shot guard (cleared once patches flow again, below)
1522
+ // prevents a reload loop if the reload doesn't resolve it.
1523
+ if (frame.message === "Unknown component" && !_reloadedForExpiry()) {
1524
+ _markReloadedForExpiry();
1525
+ console.warn(
1526
+ `[Flow] Component "${frame.component}" is no longer registered on the server ` +
1527
+ `(likely a server restart) — reloading the page to recover.`,
1528
+ );
1529
+ window.location.reload();
1530
+ return;
1531
+ }
1532
+
1533
+ // Dev error overlay: the server attaches a stack only under the dev worker, so its presence
1534
+ // means "show the full-screen overlay". The patch that follows still reconciles the component
1535
+ // underneath, so dismissing (Esc / backdrop) returns to a live page.
1536
+ const errFrame = frame as { stack?: string; name?: string; action?: string; message: string };
1537
+ if (errFrame.stack) {
1538
+ showErrorOverlay({
1539
+ message: errFrame.message,
1540
+ name: errFrame.name,
1541
+ stack: errFrame.stack,
1542
+ action: errFrame.action,
1543
+ component: frame.component ? _components.get(frame.component)?.name : undefined,
1544
+ });
1545
+ }
1546
+
1547
+ console.error(`[Flow] Server error on ${frame.component}: ${frame.message}`);
1548
+ return;
1549
+ }
1550
+
1551
+ if (frame.type === "event") {
1552
+ const ev = frame as unknown as {
1553
+ name: string;
1554
+ data: Record<string, unknown>;
1555
+ to?: string;
1556
+ self?: string;
1557
+ };
1558
+ _handleEventFrame(ev.name, ev.data, ev.to, ev.self);
1559
+ return;
1560
+ }
1561
+
1562
+ if (frame.type === "download") {
1563
+ _handleDownloadFrame(frame.filename, frame.content, frame.mime);
1564
+ return;
1565
+ }
1566
+
1567
+ // Session cookie relay — sent after WS actions that mutated @session props.
1568
+ // The browser must fetch this endpoint so the Set-Cookie header is applied.
1569
+ if (frame.type === "session") {
1570
+ try {
1571
+ await fetch(`/__flow/session-relay?t=${encodeURIComponent(frame.token as string)}`, {
1572
+ credentials: "include",
1573
+ });
1574
+ } catch {
1575
+ /* network error — non-fatal */
1576
+ }
1577
+ return;
1578
+ }
1579
+ }
1580
+
1581
+ function _syncFlowStore(comp: FlowComponent): void {
1582
+ const store = Alpine.store("flow") as Record<string, unknown> | undefined;
1583
+ if (store) {
1584
+ store[comp.id] = comp.reactive;
1585
+ }
1586
+ }
1587
+
1588
+ function _syncModelInputs(comp: FlowComponent): void {
1589
+ _ownedEls(comp, "input, textarea, select").forEach((node) => {
1590
+ const el = node as HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement;
1591
+ // Don't clobber a live-bound field the user is typing in — but only if it
1592
+ // actually holds something. An element that is focused *and* empty was just
1593
+ // created by this patch and autofocused; there is nothing to protect, and
1594
+ // skipping it leaves it permanently blank (an action that opens a focused
1595
+ // editor could never populate it).
1596
+ if (el.hasAttribute("flow:model.live") && document.activeElement === el && el.value !== "")
1597
+ return;
1598
+ const key =
1599
+ el.getAttribute("flow:model") ??
1600
+ el.getAttribute("flow:model.live") ??
1601
+ el.getAttribute("flow:model.blur");
1602
+ if (!key) return;
1603
+ const serverVal = _getPath(comp.reactive, key); // dotted keys → nested (Form fields)
1604
+ if (el instanceof HTMLInputElement && el.type === "checkbox") {
1605
+ el.checked = Boolean(serverVal);
1606
+ return;
1607
+ }
1608
+ const str = String(serverVal ?? "");
1609
+ if (el.value !== str) el.value = str;
1610
+ // A draft-backed field the server just emptied (e.g. after a successful submit) → drop the draft.
1611
+ if (el.hasAttribute("flow:draft") && str === "") _persistDraft(el, "");
1612
+ });
1613
+ }
1614
+
1615
+ function _syncDeclarative(comp: FlowComponent): void {
1616
+ _ownedEls(comp, "[flow\\:text]").forEach((el) => {
1617
+ const prop = el.getAttribute("flow:text");
1618
+ if (!prop) return;
1619
+ // Simple identifier → direct property lookup; anything else → JS expression.
1620
+ // Expressions also get `$flow` in scope so compiled client-magic reads
1621
+ // (e.g. flow:text="$flow.currentUrl(...)" from {this.currentUrl(...)}) resolve.
1622
+ if (/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(prop)) {
1623
+ el.textContent = String(comp.reactive[prop] ?? "");
1624
+ } else {
1625
+ try {
1626
+ const flow = _resolveGel(el);
1627
+ if (_bridgeCsp) {
1628
+ // CSP-safe: no `new Function`. Evaluate against reactive state + $flow.
1629
+ const scope = new Proxy({} as Record<string, unknown>, {
1630
+ get(_t, k: string | symbol): unknown {
1631
+ if (k === "$flow") return flow;
1632
+ return (comp.reactive as Record<string, unknown>)[k as string];
1633
+ },
1634
+ has(): boolean {
1635
+ return true;
1636
+ },
1637
+ });
1638
+ el.textContent = String(evaluateCsp(prop, scope) ?? "");
1639
+ } else {
1640
+ const keys = Object.keys(comp.reactive);
1641
+ const vals = keys.map((k) => (comp.reactive as Record<string, unknown>)[k]);
1642
+ el.textContent = String(
1643
+ new Function("$flow", ...keys, `return String(${prop} ?? '')`)(flow, ...vals),
1644
+ );
1645
+ }
1646
+ } catch {
1647
+ /* leave textContent unchanged on eval error */
1648
+ }
1649
+ }
1650
+ });
1651
+
1652
+ _ownedEls(comp, "[flow\\:show], [flow\\:show\\.important]").forEach((node) => {
1653
+ const el = node as HTMLElement;
1654
+ const important = el.hasAttribute("flow:show.important");
1655
+ const prop = el.getAttribute("flow:show") ?? el.getAttribute("flow:show.important");
1656
+ if (!prop) return;
1657
+ const visible = Boolean(comp.reactive[prop]);
1658
+ if (visible) {
1659
+ el.style.removeProperty("display");
1660
+ } else {
1661
+ el.style.setProperty("display", "none", important ? "important" : "");
1662
+ }
1663
+ });
1664
+
1665
+ _ownedEls(comp, "[flow\\:bind\\:class]").forEach((el) => {
1666
+ const prop = el.getAttribute("flow:bind:class");
1667
+ if (!prop) return;
1668
+ const base =
1669
+ (el as HTMLElement).dataset["flowBaseClass"] ??
1670
+ ((el as HTMLElement).dataset["flowBaseClass"] = el.className);
1671
+ const extra = String(comp.reactive[prop] ?? "");
1672
+ el.className = extra ? `${base} ${extra}`.trim() : base;
1673
+ });
1674
+
1675
+ _ownedEls(comp, "[flow\\:bind\\:href]").forEach((el) => {
1676
+ const prop = el.getAttribute("flow:bind:href");
1677
+ // Same scheme check the server renderer applies. This path is the worse of the two:
1678
+ // the value arrives from server-pushed state, so it updates without a page load.
1679
+ if (prop) el.setAttribute("href", sanitizeUrl(String(comp.reactive[prop] ?? "")));
1680
+ });
1681
+
1682
+ _ownedEls(comp, "[flow\\:bind\\:attr]").forEach((el) => {
1683
+ const spec = el.getAttribute("flow:bind:attr");
1684
+ if (!spec) return;
1685
+ let map: Record<string, string>;
1686
+ try {
1687
+ map = JSON.parse(spec) as Record<string, string>;
1688
+ } catch {
1689
+ return;
1690
+ }
1691
+ for (const [attr, prop] of Object.entries(map)) {
1692
+ const val = comp.reactive[prop];
1693
+ if (attr.startsWith("aria-")) {
1694
+ // WAI-ARIA attributes require "true"/"false" strings, not boolean present/absent
1695
+ if (val === null || val === undefined) {
1696
+ el.removeAttribute(attr);
1697
+ } else {
1698
+ el.setAttribute(attr, String(val));
1699
+ }
1700
+ } else if (val === false || val === null || val === undefined) {
1701
+ el.removeAttribute(attr);
1702
+ } else if (val === true) {
1703
+ el.setAttribute(attr, "");
1704
+ } else if (URL_ATTRIBUTES.has(attr)) {
1705
+ el.setAttribute(attr, sanitizeUrl(String(val)));
1706
+ } else {
1707
+ el.setAttribute(attr, String(val));
1708
+ }
1709
+ }
1710
+ });
1711
+
1712
+ // flow:error — display the first error message for a field
1713
+ _ownedEls(comp, "[flow\\:error]").forEach((el) => {
1714
+ const field = el.getAttribute("flow:error");
1715
+ if (!field) return;
1716
+ const errs = _getErrors(comp.id)[field];
1717
+ const msg = errs && errs.length > 0 ? errs[0]! : "";
1718
+ el.textContent = msg;
1719
+ (el as HTMLElement).style.display = msg ? "" : "none";
1720
+ });
1721
+
1722
+ // flow:errors — render the whole validation bag as a list (the <Errors> component).
1723
+ // flow:errors.only="a,b" limits it to specific fields; data-flow-errors-item sets the
1724
+ // child tag (default <li>). The container is hidden when there are no messages.
1725
+ _ownedEls(comp, "[flow\\:errors]").forEach((node) => {
1726
+ const el = node as HTMLElement;
1727
+ const bag = _getErrors(comp.id);
1728
+ const onlyRaw = el.getAttribute("flow:errors.only");
1729
+ let entries = Object.entries(bag);
1730
+ if (onlyRaw) {
1731
+ const only = onlyRaw.split(",").map((s) => s.trim());
1732
+ entries = entries.filter(([f]) => only.includes(f));
1733
+ }
1734
+ const msgs = entries.flatMap(([, m]) => m);
1735
+ if (msgs.length === 0) {
1736
+ el.style.setProperty("display", "none");
1737
+ el.innerHTML = "";
1738
+ return;
1739
+ }
1740
+ el.style.removeProperty("display");
1741
+ const tag = el.dataset["flowErrorsItem"] || "li";
1742
+ el.innerHTML = msgs.map((m) => `<${tag}>${_escapeHtml(m)}</${tag}>`).join("");
1743
+ });
1744
+
1745
+ _syncAria(comp);
1746
+ }
1747
+
1748
+ /**
1749
+ * Accessibility auto-wiring (morph-safe). Links each validation-error region to the input it
1750
+ * describes, so a screen-reader user hears the message a sighted user sees — with deterministic
1751
+ * ids that stay stable across morphs. For every `flow:error="<field>"`, ensure a stable id; for
1752
+ * every `flow:model` input whose field matches, set `aria-describedby` to that id and toggle
1753
+ * `aria-invalid`. Runs at the end of every declarative sync (scan + patch), so it re-applies
1754
+ * automatically after a morph drops the attributes. Zero author effort — it rides the existing
1755
+ * `value={this.x}` / `<span error={this.errors.x} />` props.
1756
+ */
1757
+ function _syncAria(comp: FlowComponent): void {
1758
+ const prefix = `flow-err-${comp.id}-`;
1759
+ const errorId = new Map<string, string>();
1760
+
1761
+ _ownedEls(comp, "[flow\\:error]").forEach((el) => {
1762
+ const field = el.getAttribute("flow:error");
1763
+ if (!field) return;
1764
+ let id = (el as HTMLElement).id;
1765
+ if (!id) {
1766
+ id = prefix + field.replace(/[^\w-]/g, "_");
1767
+ (el as HTMLElement).id = id;
1768
+ }
1769
+ errorId.set(field, id);
1770
+ });
1771
+ if (errorId.size === 0) return;
1772
+
1773
+ const bag = _getErrors(comp.id);
1774
+ _ownedEls(comp, "[flow\\:model], [flow\\:model\\.live], [flow\\:model\\.blur]").forEach((el) => {
1775
+ const field =
1776
+ el.getAttribute("flow:model") ??
1777
+ el.getAttribute("flow:model.live") ??
1778
+ el.getAttribute("flow:model.blur");
1779
+ if (!field) return;
1780
+ // Match by exact field or the model's last dotted segment (Form fields: "form.email" → "email").
1781
+ const last = field.includes(".") ? field.slice(field.lastIndexOf(".") + 1) : field;
1782
+ const id = errorId.get(field) ?? errorId.get(last);
1783
+ if (id) {
1784
+ // Preserve any author-provided ids; keep ours exactly once.
1785
+ const kept = (el.getAttribute("aria-describedby") ?? "")
1786
+ .split(/\s+/)
1787
+ .filter((t) => t && t !== id && !t.startsWith(prefix));
1788
+ el.setAttribute("aria-describedby", [...kept, id].join(" ").trim());
1789
+ }
1790
+ const msgs = bag[field] ?? bag[last];
1791
+ if (Array.isArray(msgs) && msgs.length > 0) el.setAttribute("aria-invalid", "true");
1792
+ else el.removeAttribute("aria-invalid");
1793
+ });
1794
+ }
1795
+
1796
+ // ── Focus management (autoFocus / focusOnError) ───────────────────────────────
1797
+
1798
+ /** True if `el` is a focusable form field. */
1799
+ function _isField(el: Element | null): el is HTMLElement {
1800
+ return (
1801
+ !!el &&
1802
+ (el instanceof HTMLInputElement ||
1803
+ el instanceof HTMLTextAreaElement ||
1804
+ el instanceof HTMLSelectElement)
1805
+ );
1806
+ }
1807
+
1808
+ /** On mount, focus the first `autoFocus` (flow:autofocus) field — once, and never stealing focus
1809
+ * the user has already placed elsewhere. */
1810
+ function _autoFocus(comp: FlowComponent): void {
1811
+ if (_isField(document.activeElement) && document.activeElement !== document.body) return;
1812
+ const el = _ownedEls(comp, "[flow\\:autofocus]").find((n) => _isField(n));
1813
+ if (el) (el as HTMLElement).focus();
1814
+ }
1815
+
1816
+ /**
1817
+ * After a patch, move focus to the first invalid `focusOnError` (flow:focus-error) field — the
1818
+ * WCAG "send focus to the first error" behaviour. It won't yank focus out of a field the user is
1819
+ * actively editing (a form control inside this component); it fires when focus sits on a
1820
+ * button/body — e.g. right after a failed submit.
1821
+ */
1822
+ function _focusFirstError(comp: FlowComponent): void {
1823
+ const bag = _getErrors(comp.id);
1824
+ if (Object.keys(bag).length === 0) return;
1825
+ const active = document.activeElement;
1826
+ if (_isField(active) && _owns(comp, active)) return; // user is in a field — leave them
1827
+ const invalid = _ownedEls(comp, "[flow\\:focus-error]").find((el) => {
1828
+ const key = _modelKey(el);
1829
+ if (!key) return false;
1830
+ const last = key.includes(".") ? key.slice(key.lastIndexOf(".") + 1) : key;
1831
+ const msgs = bag[key] ?? bag[last];
1832
+ return Array.isArray(msgs) && msgs.length > 0;
1833
+ });
1834
+ if (invalid) (invalid as HTMLElement).focus();
1835
+ }
1836
+
1837
+ // Local copy by design: this file ships to browsers as part of the client
1838
+ // bundle and cannot import server-side @zerotal/core helpers.
1839
+ function debounce<T extends unknown[]>(fn: (...args: T) => void, ms: number): (...args: T) => void {
1840
+ let timer: ReturnType<typeof setTimeout>;
1841
+ return (...args: T) => {
1842
+ clearTimeout(timer);
1843
+ timer = setTimeout(() => fn(...args), ms);
1844
+ };
1845
+ }
1846
+
1847
+ function _parseArgs(el: Element): unknown[] {
1848
+ const raw = el.getAttribute("data-args") ?? el.getAttribute("flow:args");
1849
+ if (!raw) return [];
1850
+ try {
1851
+ return JSON.parse(raw) as unknown[];
1852
+ } catch {
1853
+ return [];
1854
+ }
1855
+ }
1856
+
1857
+ async function _requireConfirm(el: Element): Promise<boolean> {
1858
+ // Rich options object (title/labels/variant/icon/prompt) encoded as JSON.
1859
+ const optsJson = el.getAttribute("flow:confirm.opts");
1860
+ if (optsJson) {
1861
+ try {
1862
+ return await confirmDialog(JSON.parse(optsJson) as ConfirmDialogOptions);
1863
+ } catch {
1864
+ return true; // malformed spec → don't block the action
1865
+ }
1866
+ }
1867
+ const promptSpec = el.getAttribute("flow:confirm.prompt");
1868
+ if (promptSpec) {
1869
+ const sep = promptSpec.lastIndexOf("|");
1870
+ const message = sep >= 0 ? promptSpec.slice(0, sep) : promptSpec;
1871
+ const expected = sep >= 0 ? promptSpec.slice(sep + 1) : "";
1872
+ return await confirmDialog({ message, prompt: expected });
1873
+ }
1874
+ const confirm = el.getAttribute("flow:confirm");
1875
+ if (confirm) return await confirmDialog({ message: confirm });
1876
+ return true;
1877
+ }
1878
+
1879
+ /** Toggle the component's failed state (drives `showOnError` / `hideOnError`). */
1880
+ function _setActionError(comp: FlowComponent, on: boolean): void {
1881
+ if (on) comp.rootEl.setAttribute("data-flow-action-error", "");
1882
+ else comp.rootEl.removeAttribute("data-flow-action-error");
1883
+ }
1884
+
1885
+ // ── Optimistic collections ─────────────────────────────────────────────────────
1886
+ // Pending optimistic list mutations per component. Applied to the reactive store instantly (so a
1887
+ // reactive `x-for`/`<For>` list shows them at once), re-applied on top of any interim server patch,
1888
+ // and dropped when the owning action's patch resolves the ack (adopt server state = reconcile/roll-
1889
+ // back). See client/optimistic.ts for the pure logic.
1890
+ const _optOps = new Map<string, OptOp[]>();
1891
+
1892
+ function _appendOptimistic(comp: FlowComponent, prop: string, item: unknown): void {
1893
+ const arr = comp.reactive[prop];
1894
+ if (!Array.isArray(arr)) return;
1895
+ comp.reactive[prop] = applyAppend(arr, item);
1896
+ (_optOps.get(comp.id) ?? _optOps.set(comp.id, []).get(comp.id)!).push({
1897
+ prop,
1898
+ kind: "append",
1899
+ item,
1900
+ });
1901
+ }
1902
+
1903
+ function _removeOptimistic(
1904
+ comp: FlowComponent,
1905
+ prop: string,
1906
+ match: (x: unknown) => boolean,
1907
+ ): void {
1908
+ const arr = comp.reactive[prop];
1909
+ if (!Array.isArray(arr)) return;
1910
+ comp.reactive[prop] = applyRemove(arr, match);
1911
+ (_optOps.get(comp.id) ?? _optOps.set(comp.id, []).get(comp.id)!).push({
1912
+ prop,
1913
+ kind: "remove",
1914
+ match,
1915
+ });
1916
+ }
1917
+
1918
+ /** Re-apply pending optimistic ops onto the just-merged server arrays (survive an interim patch). */
1919
+ function _reapplyOptOps(comp: FlowComponent): void {
1920
+ const ops = _optOps.get(comp.id);
1921
+ if (!ops) return;
1922
+ for (const op of ops) {
1923
+ const arr = comp.reactive[op.prop];
1924
+ if (!Array.isArray(arr)) continue;
1925
+ comp.reactive[op.prop] =
1926
+ op.kind === "append" ? applyAppend(arr, op.item) : applyRemove(arr, op.match!);
1927
+ }
1928
+ }
1929
+
1930
+ // Time-travel: remember the method a component last dispatched, so the patch it produces is
1931
+ // labelled with that action name in the timeline. Consumed (and cleared) when the patch lands.
1932
+ const _pendingActionByComp = new Map<string, string>();
1933
+ function _takeLastAction(compId: string): string {
1934
+ const a = _pendingActionByComp.get(compId);
1935
+ _pendingActionByComp.delete(compId);
1936
+ return a ?? "action";
1937
+ }
1938
+
1939
+ function _callAction(comp: FlowComponent, method: string, args: unknown[]): void {
1940
+ _setActionError(comp, false); // retrying clears any prior failed state
1941
+ _setLoading(comp, true, method);
1942
+ if (isTimelineEnabled()) _pendingActionByComp.set(comp.id, method);
1943
+ _dispatchFrame(comp, method, args);
1944
+ }
1945
+
1946
+ /**
1947
+ * Execute a flow:click / flow:submit value that contains $flow references.
1948
+ * Emitted from inline arrow handlers in JSX — `onClick={(e) => this.x = 0}` — where
1949
+ * `this.` is rewritten to `$flow.` at SSR time, so it evaluates against the Alpine
1950
+ * reactive proxy instead of sending a WebSocket action.
1951
+ *
1952
+ * Mirrors Alpine's own `x-on` semantics: if the expression evaluates to a function
1953
+ * (the author wrote an arrow), call it with the DOM event. A bare statement
1954
+ * (`$flow.x = 0`) evaluates directly and returns a non-function, so nothing is called.
1955
+ */
1956
+ function _evalClientExpr(comp: FlowComponent, el: Element, expr: string, event?: Event): void {
1957
+ const A = Alpine as unknown as { evaluate(el: Element, expr: string): unknown };
1958
+ try {
1959
+ const result = A.evaluate(el, expr);
1960
+ if (typeof result === "function") (result as (e?: Event) => unknown)(event);
1961
+ } catch (e) {
1962
+ console.error("[Flow] client expression error:", e, "\nExpr:", expr);
1963
+ }
1964
+ }
1965
+
1966
+ /**
1967
+ * Read every flow:model input the component owns straight from the DOM into its
1968
+ * reactive/ephemeral state. Called right before dispatching a server action
1969
+ * (a form submit, or a click on a server-action button) so the action's
1970
+ * pendingUpdates carry the latest field values even when an input's own
1971
+ * input/blur sync didn't land first — e.g. clicking submit moves focus to the
1972
+ * button before its handler runs, or a value was set without an input event.
1973
+ * Without this, "type in a field, click Save" can silently drop that field.
1974
+ */
1975
+ type ModelEl = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement;
1976
+
1977
+ /** The `flow:model` field key for an input (with any sync modifier), or null. */
1978
+ function _modelKey(el: Element): string | null {
1979
+ return (
1980
+ el.getAttribute("flow:model") ??
1981
+ el.getAttribute("flow:model.live") ??
1982
+ el.getAttribute("flow:model.blur")
1983
+ );
1984
+ }
1985
+
1986
+ /**
1987
+ * Read an input's value for sync, applying the `trim` / `number` modifiers (data-flow-trim /
1988
+ * data-flow-number, surfaced as the `trim` / `number` JSX props) plus the implicit number
1989
+ * coercion for `type="number"` inputs whose canonical value is already numeric. A checkbox
1990
+ * returns its boolean `checked`. `existing` (the current canonical value) guards the implicit
1991
+ * number coercion so a string-typed field isn't turned into a number behind the author's back.
1992
+ */
1993
+ function _readModelValue(el: ModelEl, existing?: unknown): unknown {
1994
+ if (el instanceof HTMLInputElement && el.type === "checkbox") return el.checked;
1995
+ let v = (el as HTMLInputElement).value;
1996
+ if (el.hasAttribute("data-flow-trim")) v = v.trim();
1997
+ const wantNumber =
1998
+ el.hasAttribute("data-flow-number") ||
1999
+ (el instanceof HTMLInputElement && el.type === "number" && typeof existing === "number");
2000
+ if (wantNumber) return v === "" ? null : Number(v);
2001
+ return v;
2002
+ }
2003
+
2004
+ function _flushModelInputs(comp: FlowComponent): void {
2005
+ _ownedEls(comp, "[flow\\:model], [flow\\:model\\.live], [flow\\:model\\.blur]").forEach(
2006
+ (node) => {
2007
+ const el = node as ModelEl;
2008
+ if (el instanceof HTMLInputElement && el.type === "file") return; // files upload over HTTP, not via $set
2009
+ const key = _modelKey(el);
2010
+ if (!key) return;
2011
+ // Coerce (see _readModelValue) so a number field doesn't send "0" as a string, etc.
2012
+ const value = _readModelValue(el, _getPath(comp.reactive as Record<string, unknown>, key));
2013
+ // reactive first (see set-trap note): ephemeral is the proxy's raw target.
2014
+ _setPath(comp.reactive, key, value);
2015
+ _setPath(comp.ephemeral, key, value);
2016
+ },
2017
+ );
2018
+ }
2019
+
2020
+ // ── Persisted drafts (flow:draft) ──────────────────────────────────────────────
2021
+ // `draft="key"` mirrors an input's value to localStorage so unsubmitted text survives a reload
2022
+ // or crash — a client-side safety net; the server snapshot stays the authority. Restored on
2023
+ // mount when the bound field is empty; cleared when the field is emptied (e.g. after submit).
2024
+
2025
+ const _DRAFT_NS = "flow:draft:";
2026
+
2027
+ function _persistDraft(el: Element, value: unknown): void {
2028
+ const k = el.getAttribute("flow:draft");
2029
+ if (!k) return;
2030
+ try {
2031
+ const s = typeof value === "string" ? value : String(value ?? "");
2032
+ if (s) localStorage.setItem(_DRAFT_NS + k, s);
2033
+ else localStorage.removeItem(_DRAFT_NS + k);
2034
+ } catch {
2035
+ /* storage unavailable (private mode / quota) — drafts are best-effort */
2036
+ }
2037
+ }
2038
+
2039
+ /** On mount, seed empty draft-backed fields from localStorage (held locally, flushed with the
2040
+ * next action). Never clobbers server-provided content — only fills a genuinely empty field. */
2041
+ function _restoreDrafts(comp: FlowComponent): void {
2042
+ _ownedEls(comp, "[flow\\:draft]").forEach((el) => {
2043
+ const k = el.getAttribute("flow:draft");
2044
+ const key = _modelKey(el);
2045
+ if (!k || !key) return;
2046
+ let saved: string | null = null;
2047
+ try {
2048
+ saved = localStorage.getItem(_DRAFT_NS + k);
2049
+ } catch {
2050
+ return;
2051
+ }
2052
+ if (!saved) return;
2053
+ const cur = _getPath(comp.reactive as Record<string, unknown>, key);
2054
+ if (cur !== undefined && cur !== null && cur !== "") return; // server content wins
2055
+ _setPath(comp.reactive, key, saved);
2056
+ _setPath(comp.ephemeral, key, saved);
2057
+ if ((el as HTMLInputElement).value !== undefined) (el as HTMLInputElement).value = saved;
2058
+ });
2059
+ }
2060
+
2061
+ function _setupEventDelegation(): void {
2062
+ // Toast action/onClose buttons live outside any component's DOM (they're created
2063
+ // by the <Flash> runtime script), so they reach the bridge via a window event
2064
+ // carrying the owning component id + the @expose method to invoke.
2065
+ window.addEventListener("flow:invoke", (e) => {
2066
+ const detail = (e as CustomEvent).detail as
2067
+ { component?: string; method?: string; args?: unknown[] } | undefined;
2068
+ if (!detail?.method) return;
2069
+ const comp = detail.component ? _components.get(detail.component) : undefined;
2070
+ if (!comp) return;
2071
+ _callAction(comp, detail.method, Array.isArray(detail.args) ? detail.args : []);
2072
+ });
2073
+
2074
+ document.addEventListener("click", async (e) => {
2075
+ const el = (e.target as Element).closest("[flow\\:click]") as Element | null;
2076
+ if (!el) return;
2077
+ const method = el.getAttribute("flow:click");
2078
+ if (!method) return;
2079
+ const comp = findComponentByEl(el);
2080
+ if (!comp) return;
2081
+ e.preventDefault();
2082
+
2083
+ // Async: a confirm prompt shows the styled dialog and resolves on Confirm/Cancel.
2084
+ if (!(await _requireConfirm(el))) return;
2085
+
2086
+ // Plain identifier → server action; anything else (incl. $flow exprs, arbitrary JS) → client-side eval
2087
+ if (/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(method)) {
2088
+ _flushModelInputs(comp); // capture in-progress field edits before the action
2089
+ _callAction(comp, method, _parseArgs(el));
2090
+ } else {
2091
+ _evalClientExpr(comp, el, method, e);
2092
+ }
2093
+ });
2094
+
2095
+ document.addEventListener("submit", async (e) => {
2096
+ const el = e.target as Element;
2097
+ const method = el.getAttribute("flow:submit") ?? el.getAttribute("flow:submit.prevent");
2098
+ if (!method) return;
2099
+ const comp = findComponentByEl(el);
2100
+ if (!comp) return;
2101
+ e.preventDefault();
2102
+
2103
+ if (!(await _requireConfirm(el))) return;
2104
+
2105
+ if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(method)) {
2106
+ _evalClientExpr(comp, el, method, e);
2107
+ } else {
2108
+ _flushModelInputs(comp); // capture all field values (incl. the focused one) → pendingUpdates
2109
+ const formData = new FormData(e.target as HTMLFormElement);
2110
+ const data = Object.fromEntries(
2111
+ (formData as unknown as { entries(): Iterable<[string, unknown]> }).entries(),
2112
+ );
2113
+ _callAction(comp, method, [data]);
2114
+ }
2115
+ });
2116
+
2117
+ document.addEventListener("input", (e) => {
2118
+ const el = e.target as HTMLInputElement;
2119
+ const key =
2120
+ el.getAttribute("flow:model") ??
2121
+ el.getAttribute("flow:model.live") ??
2122
+ el.getAttribute("flow:model.blur");
2123
+ if (!key) return;
2124
+ const comp = findComponentByEl(el);
2125
+ if (!comp) return;
2126
+
2127
+ // File inputs are handled by the 'change' listener (they upload over HTTP, not the WS).
2128
+ if (el.type === "file") return;
2129
+
2130
+ // Apply the trim/number modifiers on the way in, so client-reactive bindings see the coerced
2131
+ // value immediately (not just the server on the next action).
2132
+ const value = _readModelValue(el, _getPath(comp.reactive as Record<string, unknown>, key));
2133
+
2134
+ // reactive first (see set-trap note): ephemeral is the proxy's raw target, so writing it
2135
+ // first would suppress the reactive trigger and leave x-text/:class bindings stale.
2136
+ // Dotted keys (e.g. Form fields: flow:model="form.email") set a nested path.
2137
+ _setPath(comp.reactive, key, value);
2138
+ _setPath(comp.ephemeral, key, value);
2139
+
2140
+ _persistDraft(el, value); // mirror to localStorage if this input has draft="…"
2141
+
2142
+ _updateDirty(comp);
2143
+ _syncDeclarative(comp);
2144
+
2145
+ if (!el.hasAttribute("flow:model.live")) return;
2146
+
2147
+ // Debounce a text input's keystrokes (default); discrete controls send immediately.
2148
+ _debouncedModelSend(comp, key, value, _modelDebounceMs(el));
2149
+ });
2150
+
2151
+ // File inputs bound with flow:model — upload bytes over HTTP, then $set the signed reference.
2152
+ document.addEventListener("change", (e) => {
2153
+ const el = e.target as HTMLInputElement;
2154
+ if (el.type !== "file") return;
2155
+ const key = el.getAttribute("flow:model") ?? el.getAttribute("flow:model.live");
2156
+ if (!key) return;
2157
+ const comp = findComponentByEl(el);
2158
+ if (!comp || !el.files || el.files.length === 0) return;
2159
+ _uploadFiles(comp, key, Array.from(el.files), el.multiple);
2160
+ el.value = ""; // allow re-selecting the same file
2161
+ });
2162
+
2163
+ document.addEventListener(
2164
+ "blur",
2165
+ (e) => {
2166
+ const el = e.target as Element;
2167
+ const comp = findComponentByEl(el);
2168
+ if (!comp) return;
2169
+
2170
+ const key = el.getAttribute("flow:model.blur");
2171
+ if (key) _callAction(comp, "$set", [key, (el as HTMLInputElement).value]);
2172
+
2173
+ // `onBlur={this.method}` — the JSX runtime emits `flow:blur` for it like any
2174
+ // other `on*` prop, so without this the attribute renders and nothing ever
2175
+ // calls it. Dispatched the same way as flow:click: a bare identifier is a
2176
+ // server action, anything else is a client expression.
2177
+ const action = el.getAttribute("flow:blur");
2178
+ if (!action) return;
2179
+ if (/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(action)) {
2180
+ // Flush first, so an action that reads the field sees what was typed
2181
+ // rather than the value as of the last sync.
2182
+ _flushModelInputs(comp);
2183
+ _callAction(comp, action, _parseArgs(el));
2184
+ } else {
2185
+ _evalClientExpr(comp, el, action, e);
2186
+ }
2187
+ },
2188
+ true,
2189
+ );
2190
+ }
2191
+
2192
+ // ── SPA Navigation (flow:navigate / flow:current) ───────────────────────────────
2193
+
2194
+ function _linkActive(href: string, path: string, exact = false): boolean {
2195
+ const h = href.split("?")[0] ?? href; // compare pathname only
2196
+ if (exact) return h === path;
2197
+ return h === path || (h.length > 1 && path.startsWith(h + "/"));
2198
+ }
2199
+
2200
+ function _updateCurrentLinks(): void {
2201
+ const path = window.location.pathname;
2202
+
2203
+ // Auto data-current on every navigate link matching the URL (Livewire behavior).
2204
+ // Style with `class="data-current:font-bold"` or `[data-current] {}`. Opt out
2205
+ // per-link with `flow:current.ignore` (the <Link current={false}> prop), or force it
2206
+ // always-on with `flow:current.force` (the <Link current={true}> prop) so the link stays
2207
+ // active across SPA navigations regardless of whether its href matches the URL.
2208
+ document.querySelectorAll("a[flow\\:navigate]").forEach((el) => {
2209
+ if (el.hasAttribute("flow:current.ignore")) return;
2210
+ if (el.hasAttribute("flow:current.force")) {
2211
+ el.setAttribute("data-current", "");
2212
+ return;
2213
+ }
2214
+ const exact = el.hasAttribute("flow:current.exact");
2215
+ if (_linkActive(el.getAttribute("href") ?? "", path, exact))
2216
+ el.setAttribute("data-current", "");
2217
+ else el.removeAttribute("data-current");
2218
+ });
2219
+
2220
+ document.querySelectorAll("a[flow\\:navigate][flow\\:current]").forEach((el) => {
2221
+ const href = el.getAttribute("href") ?? "";
2222
+ const activeClass = el.getAttribute("flow:current") ?? "";
2223
+ if (!activeClass) return;
2224
+ if (!el.hasAttribute("data-flow-base-class")) {
2225
+ el.setAttribute("data-flow-base-class", el.getAttribute("class") ?? "");
2226
+ }
2227
+ const base = el.getAttribute("data-flow-base-class") ?? "";
2228
+ const isActive = href === path || (href.length > 1 && path.startsWith(href + "/"));
2229
+ el.setAttribute("class", isActive ? `${base} ${activeClass}`.trim() : base);
2230
+ });
2231
+ }
2232
+
2233
+ // ── Hover prefetch ────────────────────────────────────────────────────────────
2234
+ // `<a … navigate hover>` (flow:navigate.hover) fetches the target after a short dwell
2235
+ // and caches the HTML, so the click swaps instantly (beats a cold fetch on slow links).
2236
+
2237
+ const _prefetchCache = new Map<string, { html: string; at: number }>();
2238
+ const _prefetchInflight = new Set<string>();
2239
+ const PREFETCH_MAX = 10;
2240
+ const PREFETCH_TTL_MS = 30_000;
2241
+ const PREFETCH_DWELL_MS = 60;
2242
+
2243
+ function _prefetch(href: string): void {
2244
+ if (_prefetchInflight.has(href)) return;
2245
+ const cached = _prefetchCache.get(href);
2246
+ if (cached && Date.now() - cached.at < PREFETCH_TTL_MS) return;
2247
+ _prefetchInflight.add(href);
2248
+ void fetch(href, { credentials: "include", headers: { Accept: "text/html" } })
2249
+ .then(async (res) => {
2250
+ if (res.ok && (res.headers.get("content-type") ?? "").includes("text/html")) {
2251
+ _prefetchCache.set(href, { html: await res.text(), at: Date.now() });
2252
+ while (_prefetchCache.size > PREFETCH_MAX) {
2253
+ const oldest = _prefetchCache.keys().next().value; // insertion-order = LRU-ish
2254
+ if (oldest === undefined) break;
2255
+ _prefetchCache.delete(oldest);
2256
+ }
2257
+ }
2258
+ })
2259
+ .catch(() => {})
2260
+ .finally(() => _prefetchInflight.delete(href));
2261
+ }
2262
+
2263
+ /** Consume a fresh prefetched page for `href`, or null. Consuming evicts it. */
2264
+ function _takePrefetched(href: string): string | null {
2265
+ const cached = _prefetchCache.get(href);
2266
+ if (!cached) return null;
2267
+ _prefetchCache.delete(href);
2268
+ return Date.now() - cached.at < PREFETCH_TTL_MS ? cached.html : null;
2269
+ }
2270
+
2271
+ async function _navigateTo(href: string, pushState = true): Promise<void> {
2272
+ let html: string;
2273
+ const prefetched = _takePrefetched(href);
2274
+ if (prefetched !== null) {
2275
+ html = prefetched; // instant — no network round-trip on click
2276
+ } else {
2277
+ try {
2278
+ const res = await fetch(href, { credentials: "include", headers: { Accept: "text/html" } });
2279
+ if (!res.ok || !(res.headers.get("content-type") ?? "").includes("text/html")) {
2280
+ window.location.href = href;
2281
+ return;
2282
+ }
2283
+ html = await res.text();
2284
+ } catch {
2285
+ window.location.href = href;
2286
+ return;
2287
+ }
2288
+ }
2289
+
2290
+ const parser = new DOMParser();
2291
+ const doc = parser.parseFromString(html, "text/html");
2292
+ const currentLayout = document.querySelector<HTMLElement>("[data-flow-layout]");
2293
+ const incomingLayout = doc.querySelector<HTMLElement>("[data-flow-layout]");
2294
+ const sameLayout =
2295
+ currentLayout &&
2296
+ incomingLayout &&
2297
+ currentLayout.dataset["flowLayout"] === incomingLayout.dataset["flowLayout"];
2298
+
2299
+ if (!sameLayout) {
2300
+ // Crossing layouts means a different document shell (and its own runtime).
2301
+ // Do a real browser navigation so the new page loads in a fresh realm: a
2302
+ // document.write() here would re-inject the classic runtime <script> into the
2303
+ // *current* realm and redeclare its top-level bindings (e.g. `class
2304
+ // FlowComponent` → "already declared"). A hard navigation also re-bootstraps
2305
+ // the client cleanly for the new layout.
2306
+ if (pushState) window.location.assign(href);
2307
+ else window.location.reload();
2308
+ return;
2309
+ }
2310
+
2311
+ const incomingRoot = incomingLayout.querySelector<HTMLElement>("[data-flow-root]");
2312
+ const currentRoot = currentLayout.querySelector<HTMLElement>("[data-flow-root]");
2313
+ if (!incomingRoot || !currentRoot) {
2314
+ window.location.href = href;
2315
+ return;
2316
+ }
2317
+
2318
+ const newId = incomingRoot.dataset["flowId"] ?? "";
2319
+ const incomingState = doc.getElementById(`flow-state-${newId}`);
2320
+ if (!incomingState) {
2321
+ window.location.href = href;
2322
+ return;
2323
+ }
2324
+
2325
+ // <Persist>: move live persisted elements (with their state — a playing <audio>,
2326
+ // scroll position, …) from the current root into the incoming clone so they're
2327
+ // re-used instead of replaced.
2328
+ const incomingClone = incomingRoot.cloneNode(true) as HTMLElement;
2329
+ _carryPersisted(currentRoot, incomingClone);
2330
+
2331
+ // Swap state script + component root. Wrapped in a View Transition when the
2332
+ // browser supports it, for a smooth cross-page animation; falls back to an
2333
+ // instant swap otherwise.
2334
+ const _swap = (): void => {
2335
+ document.querySelector('[id^="flow-state-"]')?.remove();
2336
+ document.body.appendChild(incomingState.cloneNode(true));
2337
+ currentRoot.replaceWith(incomingClone);
2338
+ _cleanupDisconnected();
2339
+ _scanComponents();
2340
+ _processHead(doc); // re-apply head from the incoming page (shell stylesheet + page meta)
2341
+ };
2342
+ const _startVT = (document as unknown as { startViewTransition?: (cb: () => void) => unknown })
2343
+ .startViewTransition;
2344
+ const _reduceMotion =
2345
+ typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
2346
+ if (typeof _startVT === "function" && !_reduceMotion) _startVT.call(document, _swap);
2347
+ else _swap(); // reduced-motion (or unsupported): swap instantly, no cross-page animation
2348
+
2349
+ if (pushState) history.pushState({ flowNavigate: true }, "", href);
2350
+ const incomingTitle = doc.querySelector("title");
2351
+ if (incomingTitle) document.title = incomingTitle.textContent ?? "";
2352
+ _updateCurrentLinks();
2353
+ }
2354
+
2355
+ /** Move live `[data-flow-persist]` elements from `fromEl` into matching slots in `toEl`. */
2356
+ function _carryPersisted(fromEl: Element, toEl: Element): void {
2357
+ fromEl.querySelectorAll("[data-flow-persist]").forEach((live) => {
2358
+ const name = (live as HTMLElement).dataset["flowPersist"];
2359
+ if (!name) return;
2360
+ const slot = toEl.querySelector(`[data-flow-persist="${CSS.escape(name)}"]`);
2361
+ if (slot) slot.replaceWith(live); // re-use the live node (preserves its state)
2362
+ });
2363
+ }
2364
+
2365
+ /**
2366
+ * Hoist `<template data-flow-head>` content (from <Head>) into document.head.
2367
+ *
2368
+ * On a same-layout `navigate` the shell (and its `<Head>` template) isn't part of
2369
+ * the swapped-in page root, and the initial hoist removes the template from the
2370
+ * live DOM — so read from `source` (the incoming parsed document on navigation),
2371
+ * which still carries every head template (shell-level stylesheet *and* page-level
2372
+ * title/meta). Dedupe by identity so an unchanged stylesheet is left in place
2373
+ * rather than removed-and-re-added (which would flash unstyled on every visit).
2374
+ */
2375
+ function _processHead(source: ParentNode = document): void {
2376
+ const keyOf = (el: Element): string =>
2377
+ el.tagName +
2378
+ "|" +
2379
+ (el.getAttribute("href") ?? el.getAttribute("name") ?? el.getAttribute("rel") ?? el.outerHTML);
2380
+
2381
+ const desired: HTMLElement[] = [];
2382
+ source.querySelectorAll("template[data-flow-head]").forEach((tplEl) => {
2383
+ const tpl = tplEl as HTMLTemplateElement;
2384
+ const frag = tpl.content.cloneNode(true) as DocumentFragment;
2385
+ for (const el of Array.from(frag.children)) {
2386
+ if (el.tagName === "TITLE") {
2387
+ document.title = el.textContent ?? "";
2388
+ continue;
2389
+ }
2390
+ desired.push(el as HTMLElement);
2391
+ }
2392
+ tpl.remove();
2393
+ });
2394
+
2395
+ const existing = new Map<string, Element>();
2396
+ document.head
2397
+ .querySelectorAll("[data-flow-head-managed]")
2398
+ .forEach((n) => existing.set(keyOf(n), n));
2399
+
2400
+ const keep = new Set<string>();
2401
+ for (const el of desired) {
2402
+ const k = keyOf(el);
2403
+ keep.add(k);
2404
+ if (existing.has(k)) continue; // already applied — leave it untouched (no flash)
2405
+ el.setAttribute("data-flow-head-managed", "");
2406
+ document.head.appendChild(el);
2407
+ }
2408
+ for (const [k, n] of existing) if (!keep.has(k)) n.remove(); // drop stale entries
2409
+ }
2410
+
2411
+ /** Delegated hover/touch prefetch for `a[flow:navigate.hover]` links. */
2412
+ function _setupPrefetch(): void {
2413
+ const sameOrigin = (a: HTMLAnchorElement): string | null => {
2414
+ const href = a.getAttribute("href");
2415
+ if (!href || href.startsWith("#") || href.includes(":")) return null;
2416
+ try {
2417
+ if (new URL(href, location.origin).origin !== location.origin) return null;
2418
+ } catch {
2419
+ return null;
2420
+ }
2421
+ return href;
2422
+ };
2423
+
2424
+ let hoverHref: string | null = null;
2425
+ let timer: ReturnType<typeof setTimeout> | null = null;
2426
+
2427
+ // pointerover bubbles (unlike pointerenter), so one delegated listener covers all links.
2428
+ document.addEventListener("pointerover", (e) => {
2429
+ const a = (e.target as Element).closest?.(
2430
+ "a[flow\\:navigate\\.hover]",
2431
+ ) as HTMLAnchorElement | null;
2432
+ const href = a ? sameOrigin(a) : null;
2433
+ if (href === hoverHref) return; // still hovering the same link — don't restart the dwell
2434
+ hoverHref = href;
2435
+ if (timer) clearTimeout(timer);
2436
+ if (href) timer = setTimeout(() => _prefetch(href), PREFETCH_DWELL_MS);
2437
+ });
2438
+
2439
+ // Mobile has no hover — prefetch on first touch (still ahead of the click).
2440
+ document.addEventListener(
2441
+ "touchstart",
2442
+ (e) => {
2443
+ const a = (e.target as Element).closest?.(
2444
+ "a[flow\\:navigate\\.hover]",
2445
+ ) as HTMLAnchorElement | null;
2446
+ const href = a ? sameOrigin(a) : null;
2447
+ if (href) _prefetch(href);
2448
+ },
2449
+ { passive: true },
2450
+ );
2451
+ }
2452
+
2453
+ function _setupNavigationLinks(): void {
2454
+ _updateCurrentLinks();
2455
+ _setupPrefetch();
2456
+
2457
+ // Stamp the initial history entry as ours. A fresh page load has `history.state === null`,
2458
+ // so without this, pressing Back to the first page fires `popstate` with a null state — the
2459
+ // handler below ignores it and the URL changes while the page stays put. Replacing (not
2460
+ // pushing) keeps the entry in place; it just gains the flowNavigate marker.
2461
+ if (!(history.state as { flowNavigate?: boolean } | null)?.flowNavigate) {
2462
+ history.replaceState(
2463
+ { ...(history.state as object | null), flowNavigate: true },
2464
+ "",
2465
+ location.href,
2466
+ );
2467
+ }
2468
+
2469
+ document.addEventListener("click", (e) => {
2470
+ const anchor = (e.target as Element).closest("a[flow\\:navigate]") as HTMLAnchorElement | null;
2471
+ if (!anchor) return;
2472
+ const href = anchor.getAttribute("href");
2473
+ if (!href || href.startsWith("#") || href.includes(":")) return;
2474
+ try {
2475
+ if (new URL(href, window.location.origin).origin !== window.location.origin) return;
2476
+ } catch {
2477
+ return;
2478
+ }
2479
+ e.preventDefault();
2480
+ void _navigateTo(href);
2481
+ });
2482
+
2483
+ window.addEventListener("popstate", (e) => {
2484
+ if ((e.state as { flowNavigate?: boolean } | null)?.flowNavigate) {
2485
+ void _navigateTo(window.location.pathname + window.location.search, false);
2486
+ }
2487
+ });
2488
+ }
2489
+
2490
+ /**
2491
+ * Query-aware SPA-navigation helpers exposed on `$flow` (and `this` in components):
2492
+ * $flow.currentUrl({ query, hash }) — build a URL from the current one, no nav
2493
+ * $flow.navigateCurrent({ query, hash }) — build that URL and SPA-navigate to it
2494
+ *
2495
+ * Both read the live URL from window.location (kept in sync by flow:navigate).
2496
+ * Ideal for instant filters, e.g.:
2497
+ * <select @change="$flow.navigateCurrent({ query: { status: $event.target.value || null } })">
2498
+ */
2499
+ function _currentUrl(options: CurrentUrlOptions = {}): string {
2500
+ return buildUrlWithQuery(window.location.href, options);
2501
+ }
2502
+ function _navigateCurrent(options: CurrentUrlOptions = {}): Promise<void> {
2503
+ return _navigateTo(buildUrlWithQuery(window.location.href, options));
2504
+ }
2505
+
2506
+ const _debouncedModelSend = (() => {
2507
+ const timers = new Map<string, ReturnType<typeof setTimeout>>();
2508
+ return (comp: FlowComponent, key: string, value: unknown, ms: number) => {
2509
+ const timerKey = `${comp.id}:${key}`;
2510
+ clearTimeout(timers.get(timerKey));
2511
+ if (ms === 0) {
2512
+ _callAction(comp, "$set", [key, value]);
2513
+ } else {
2514
+ timers.set(
2515
+ timerKey,
2516
+ setTimeout(() => _callAction(comp, "$set", [key, value]), ms),
2517
+ );
2518
+ }
2519
+ };
2520
+ })();
2521
+
2522
+ // ── File uploads ───────────────────────────────────────────────────────────────
2523
+ // POST each file to /__flow/upload (XHR for progress), collect the signed references,
2524
+ // then $set them onto the bound property (single value, or array for `multiple`). Emits
2525
+ // flow:upload-start / -progress / -finish / -error CustomEvents on window for the UI.
2526
+
2527
+ function _uploadOne(file: File, key: string): Promise<unknown> {
2528
+ return new Promise((resolve, reject) => {
2529
+ const fd = new FormData();
2530
+ fd.append("file", file);
2531
+ const xhr = new XMLHttpRequest();
2532
+ xhr.open("POST", "/__flow/upload");
2533
+ xhr.withCredentials = true;
2534
+ xhr.upload.onprogress = (ev) => {
2535
+ if (!ev.lengthComputable) return;
2536
+ const percent = Math.round((ev.loaded / ev.total) * 100);
2537
+ window.dispatchEvent(
2538
+ new CustomEvent("flow:upload-progress", { detail: { key, name: file.name, percent } }),
2539
+ );
2540
+ };
2541
+ xhr.onload = () => {
2542
+ if (xhr.status >= 200 && xhr.status < 300) {
2543
+ try {
2544
+ resolve(JSON.parse(xhr.responseText));
2545
+ } catch {
2546
+ reject(new Error("Bad upload response"));
2547
+ }
2548
+ } else {
2549
+ let msg = `Upload failed (${xhr.status})`;
2550
+ try {
2551
+ msg = (JSON.parse(xhr.responseText) as { error?: string }).error ?? msg;
2552
+ } catch {
2553
+ /* keep default */
2554
+ }
2555
+ reject(new Error(msg));
2556
+ }
2557
+ };
2558
+ xhr.onerror = () => reject(new Error("Network error during upload"));
2559
+ xhr.send(fd);
2560
+ });
2561
+ }
2562
+
2563
+ function _uploadFiles(comp: FlowComponent, key: string, files: File[], multiple: boolean): void {
2564
+ window.dispatchEvent(
2565
+ new CustomEvent("flow:upload-start", { detail: { key, count: files.length } }),
2566
+ );
2567
+ void (async () => {
2568
+ try {
2569
+ const refs: unknown[] = [];
2570
+ for (const f of files) refs.push(await _uploadOne(f, key));
2571
+ const value = multiple ? refs : refs[0];
2572
+ // reactive first (see set-trap note): ephemeral is the proxy's raw target.
2573
+ comp.reactive[key] = value;
2574
+ comp.ephemeral[key] = value;
2575
+ _debouncedModelSend(comp, key, value, 0);
2576
+ window.dispatchEvent(new CustomEvent("flow:upload-finish", { detail: { key } }));
2577
+ } catch (err) {
2578
+ window.dispatchEvent(
2579
+ new CustomEvent("flow:upload-error", {
2580
+ detail: { key, error: String((err as Error).message ?? err) },
2581
+ }),
2582
+ );
2583
+ }
2584
+ })();
2585
+ }
2586
+
2587
+ function _parseDelay(modifier: string): number {
2588
+ if (modifier.endsWith("ms")) return parseInt(modifier, 10);
2589
+ if (modifier.endsWith("s")) return parseInt(modifier, 10) * 1_000;
2590
+ return parseInt(modifier, 10) * 1_000;
2591
+ }
2592
+
2593
+ function _setupPolling(comp: FlowComponent): void {
2594
+ (_intervals.get(comp.id) ?? []).forEach(clearInterval);
2595
+
2596
+ const list: ReturnType<typeof setInterval>[] = [];
2597
+ const walker = document.createTreeWalker(comp.rootEl, NodeFilter.SHOW_ELEMENT);
2598
+ let node: Node | null = comp.rootEl;
2599
+ while (node) {
2600
+ const el = node as Element;
2601
+ if (_owns(comp, el)) {
2602
+ for (const attr of Array.from(el.attributes)) {
2603
+ if (!attr.name.startsWith("flow:poll")) continue;
2604
+
2605
+ const modifier = attr.name.replace("flow:poll", "").replace(/^\./, "");
2606
+ const keepAlive = modifier.includes("keep-alive");
2607
+ const visible = modifier.includes("visible");
2608
+ const delayPart = modifier
2609
+ .replace("keep-alive", "")
2610
+ .replace("visible", "")
2611
+ .replace(/^\.+|\.+$/g, "")
2612
+ .replace(/\.\./, ".");
2613
+ const ms = delayPart ? _parseDelay(delayPart) : 5_000;
2614
+ const method = attr.value || "$refresh";
2615
+
2616
+ list.push(
2617
+ setInterval(() => {
2618
+ if (visible) {
2619
+ const r = el.getBoundingClientRect();
2620
+ const inView = r.bottom > 0 && r.top < innerHeight;
2621
+ if (!inView) return;
2622
+ }
2623
+ if (document.hidden && !keepAlive) return;
2624
+ _callAction(comp, method, []);
2625
+ }, ms),
2626
+ );
2627
+ }
2628
+ }
2629
+ node = walker.nextNode();
2630
+ }
2631
+ _intervals.set(comp.id, list);
2632
+ }
2633
+
2634
+ function _setupIntersect(comp: FlowComponent): void {
2635
+ (_observers.get(comp.id) ?? []).forEach((o) => o.disconnect());
2636
+
2637
+ const list: IntersectionObserver[] = [];
2638
+ _ownedEls(comp, "[flow\\:intersect], [flow\\:intersect\\.leave]").forEach((el) => {
2639
+ const enter = el.getAttribute("flow:intersect");
2640
+ const leave = el.getAttribute("flow:intersect.leave");
2641
+
2642
+ // Alpine x-intersect parity: `.once` fires enter once then disconnects; `threshold`
2643
+ // (half | full | 0..1) sets how much must be visible; `margin` sets the observer rootMargin
2644
+ // (e.g. "200px" to fire before the element enters). Surfaced as the intersectOnce /
2645
+ // intersectThreshold / intersectMargin JSX props.
2646
+ const once = el.hasAttribute("flow:intersect.once");
2647
+ const thRaw = el.getAttribute("flow:intersect.threshold");
2648
+ const threshold =
2649
+ thRaw === "full" ? 0.99 : thRaw === "half" ? 0.5 : thRaw != null ? Number(thRaw) : 0;
2650
+ const margin = el.getAttribute("flow:intersect.margin");
2651
+ const opts: IntersectionObserverInit = { threshold };
2652
+ if (margin) opts.rootMargin = margin;
2653
+
2654
+ let wasVisible = false;
2655
+ const obs = new IntersectionObserver((entries) => {
2656
+ for (const entry of entries) {
2657
+ if (entry.isIntersecting && !wasVisible) {
2658
+ wasVisible = true;
2659
+ if (enter) _callAction(comp, enter, _parseArgs(el));
2660
+ if (once) obs.disconnect();
2661
+ } else if (!entry.isIntersecting && wasVisible) {
2662
+ wasVisible = false;
2663
+ if (leave) _callAction(comp, leave, _parseArgs(el));
2664
+ }
2665
+ }
2666
+ }, opts);
2667
+ obs.observe(el);
2668
+ list.push(obs);
2669
+ });
2670
+ _observers.set(comp.id, list);
2671
+ }
2672
+
2673
+ type DragState = {
2674
+ item: Element;
2675
+ container: Element;
2676
+ group: string | null;
2677
+ } | null;
2678
+
2679
+ let _dragState: DragState = null;
2680
+
2681
+ function _sortItems(container: Element): Element[] {
2682
+ return Array.from(container.children).filter(
2683
+ (c) => c.hasAttribute("flow:sort:item") && !c.hasAttribute("flow:sort:ignore"),
2684
+ );
2685
+ }
2686
+
2687
+ function _setupSort(comp: FlowComponent): void {
2688
+ _ownedEls(comp, "[flow\\:sort]").forEach((container) => {
2689
+ if ((container as HTMLElement).dataset["flowSortBound"]) return;
2690
+ (container as HTMLElement).dataset["flowSortBound"] = "1";
2691
+ const group = container.getAttribute("flow:sort:group");
2692
+
2693
+ container.addEventListener("dragstart", (e) => {
2694
+ const item = (e.target as Element).closest?.("[flow\\:sort\\:item]");
2695
+ if (!item || item.parentElement !== container) return;
2696
+ _dragState = { item, container, group };
2697
+ const dt = (e as DragEvent).dataTransfer;
2698
+ if (dt) {
2699
+ dt.effectAllowed = "move";
2700
+ dt.setData("text/plain", item.getAttribute("flow:sort:item") ?? "");
2701
+ }
2702
+ });
2703
+
2704
+ container.addEventListener("dragover", (e) => {
2705
+ if (!_dragState) return;
2706
+ const sameContainer = _dragState.container === container;
2707
+ const sameGroup = group !== null && _dragState.group === group;
2708
+ if (!sameContainer && !sameGroup) return;
2709
+ e.preventDefault();
2710
+
2711
+ const y = (e as DragEvent).clientY;
2712
+ const items = _sortItems(container).filter((i) => i !== _dragState!.item);
2713
+ let before: Element | null = null;
2714
+ for (const it of items) {
2715
+ const r = (it as HTMLElement).getBoundingClientRect();
2716
+ if (y < r.top + r.height / 2) {
2717
+ before = it;
2718
+ break;
2719
+ }
2720
+ }
2721
+ if (before) container.insertBefore(_dragState.item, before);
2722
+ else container.appendChild(_dragState.item);
2723
+ });
2724
+
2725
+ container.addEventListener("drop", (e) => {
2726
+ if (_dragState) e.preventDefault();
2727
+ });
2728
+
2729
+ container.addEventListener("dragend", () => {
2730
+ if (!_dragState) return;
2731
+ const { item } = _dragState;
2732
+ _dragState = null;
2733
+
2734
+ const targetContainer = item.parentElement;
2735
+ if (!targetContainer) return;
2736
+
2737
+ const key = item.getAttribute("flow:sort:item");
2738
+ const pos = _sortItems(targetContainer).indexOf(item);
2739
+ const method = targetContainer.getAttribute("flow:sort");
2740
+ const owner = findComponentByEl(targetContainer);
2741
+ if (owner && method && key !== null && pos !== -1) {
2742
+ _callAction(owner, method, [key, pos]);
2743
+ }
2744
+ });
2745
+ });
2746
+
2747
+ _ownedEls(comp, "[flow\\:sort\\:item]").forEach((node) => {
2748
+ const item = node as HTMLElement;
2749
+ if (item.dataset["flowSortItemBound"]) return;
2750
+ item.dataset["flowSortItemBound"] = "1";
2751
+
2752
+ const handle = item.querySelector("[flow\\:sort\\:handle]");
2753
+ if (handle) {
2754
+ item.draggable = false;
2755
+ handle.addEventListener("mousedown", () => {
2756
+ item.draggable = true;
2757
+ });
2758
+ item.addEventListener("dragend", () => {
2759
+ item.draggable = false;
2760
+ });
2761
+ document.addEventListener("mouseup", () => {
2762
+ item.draggable = false;
2763
+ });
2764
+ } else {
2765
+ item.draggable = true;
2766
+ }
2767
+ });
2768
+ }
2769
+
2770
+ function _refreshComponentFeatures(comp: FlowComponent): void {
2771
+ _setupPolling(comp);
2772
+ _setupIntersect(comp);
2773
+ _setupSort(comp);
2774
+ }
2775
+
2776
+ // ── Lazy / defer loading ──────────────────────────────────────────────────────
2777
+
2778
+ function _setupLazyComponents(): void {
2779
+ // [data-flow-lazy]: load when enters viewport
2780
+ document.querySelectorAll<HTMLElement>("[data-flow-root][data-flow-lazy]").forEach((root) => {
2781
+ const id = root.dataset["flowId"];
2782
+ if (!id || _lazyObservers.has(id)) return;
2783
+ const comp = _components.get(id);
2784
+ if (!comp) return;
2785
+
2786
+ const obs = new IntersectionObserver(
2787
+ (entries) => {
2788
+ for (const entry of entries) {
2789
+ if (!entry.isIntersecting) continue;
2790
+ obs.disconnect();
2791
+ _lazyObservers.delete(id);
2792
+ root.removeAttribute("data-flow-lazy");
2793
+ _callAction(comp, "$mount", []);
2794
+ break;
2795
+ }
2796
+ },
2797
+ { rootMargin: "100px" },
2798
+ );
2799
+ obs.observe(root);
2800
+ _lazyObservers.set(id, obs);
2801
+ });
2802
+
2803
+ // [data-flow-defer]: load immediately after current paint cycle
2804
+ document.querySelectorAll<HTMLElement>("[data-flow-root][data-flow-defer]").forEach((root) => {
2805
+ const id = root.dataset["flowId"];
2806
+ if (!id) return;
2807
+ const comp = _components.get(id);
2808
+ if (!comp) return;
2809
+ root.removeAttribute("data-flow-defer");
2810
+ // Use requestIdleCallback if available, otherwise setTimeout
2811
+ const schedule =
2812
+ (window as unknown as { requestIdleCallback?: (fn: () => void) => void })
2813
+ .requestIdleCallback ?? ((fn: () => void) => setTimeout(fn, 0));
2814
+ schedule(() => _callAction(comp, "$mount", []));
2815
+ });
2816
+ }
2817
+
2818
+ // ── Component scanning ────────────────────────────────────────────────────────
2819
+
2820
+ /** Scan the DOM for Flow component roots and hydrate any not yet tracked. */
2821
+ function _scanComponents(): void {
2822
+ document.querySelectorAll<HTMLElement>("[data-flow-root][data-flow-id]").forEach((root) => {
2823
+ const id = root.dataset["flowId"];
2824
+ const name = root.dataset["flowName"];
2825
+ if (!id || !name || _components.has(id)) return;
2826
+
2827
+ const stateEl = document.getElementById(`flow-state-${id}`);
2828
+ if (!stateEl) return;
2829
+
2830
+ let snapshot: Snapshot;
2831
+ try {
2832
+ snapshot = JSON.parse(stateEl.textContent ?? "") as Snapshot;
2833
+ } catch {
2834
+ return;
2835
+ }
2836
+
2837
+ // FlowComponent constructor sets up reactive/canonical/ephemeral state.
2838
+ const comp = new FlowComponent(root, snapshot);
2839
+ _components.set(id, comp);
2840
+ _syncFlowStore(comp);
2841
+ _restoreDrafts(comp); // seed empty draft-backed fields from localStorage before first paint
2842
+ _syncModelInputs(comp);
2843
+ _syncDeclarative(comp);
2844
+ _syncUrlParams(comp);
2845
+ _autoFocus(comp); // focus a flow:autofocus field on mount
2846
+ _setupPolling(comp);
2847
+ _setupIntersect(comp);
2848
+ _setupSort(comp);
2849
+ _setupEchoListeners(comp);
2850
+ _setupPresence(comp);
2851
+ _setupShared(comp);
2852
+
2853
+ // Time-travel: seed the timeline with this component's initial (mount) frame. No-op unless
2854
+ // recording is enabled (dev). Runs only for newly-tracked roots (the `_components.has` guard
2855
+ // above), so a re-scan after a morph doesn't duplicate it.
2856
+ if (isTimelineEnabled()) {
2857
+ _recordTimelineFrame({
2858
+ compId: id,
2859
+ compName: name,
2860
+ action: "mount",
2861
+ snapshot,
2862
+ html: root.outerHTML,
2863
+ });
2864
+ }
2865
+
2866
+ // Register @modelable child→parent map + reactive baselines from the SSR root,
2867
+ // so the first parent-pushed/child-originated change is diffed against a real baseline.
2868
+ const modelRaw = root.getAttribute("data-flow-model");
2869
+ if (modelRaw) {
2870
+ try {
2871
+ _modelMaps.set(id, JSON.parse(modelRaw) as Record<string, string>);
2872
+ } catch {
2873
+ /* ignore */
2874
+ }
2875
+ }
2876
+ const propsRaw = root.getAttribute("data-flow-props");
2877
+ if (propsRaw) {
2878
+ try {
2879
+ const props = JSON.parse(propsRaw) as Record<string, unknown>;
2880
+ const baseline = _modelBaselineFor(id);
2881
+ for (const [k, v] of Object.entries(props)) baseline.set(k, JSON.stringify(v));
2882
+ } catch {
2883
+ /* ignore */
2884
+ }
2885
+ }
2886
+ });
2887
+
2888
+ _processTeleports();
2889
+ _setupLazyComponents();
2890
+ }
2891
+
2892
+ // ── Cleanup disconnected components ───────────────────────────────────────────
2893
+
2894
+ function _cleanupDisconnected(): void {
2895
+ for (const [id, comp] of _components) {
2896
+ if (!document.contains(comp.rootEl)) {
2897
+ _components.delete(id);
2898
+ _errors.delete(id);
2899
+ _watchers.delete(id);
2900
+ _modelMaps.delete(id);
2901
+ _modelBaselines.delete(id);
2902
+ _offlineQueues.delete(id);
2903
+ _queues.delete(id); // per-component outbound frame chain
2904
+ clearTimeout(_loadingTimers.get(id)); // pending loading-delay timer
2905
+ _loadingTimers.delete(id);
2906
+ (_intervals.get(id) ?? []).forEach(clearInterval);
2907
+ _intervals.delete(id);
2908
+ (_observers.get(id) ?? []).forEach((o) => o.disconnect());
2909
+ _observers.delete(id);
2910
+ const obs = _lazyObservers.get(id);
2911
+ if (obs) {
2912
+ obs.disconnect();
2913
+ _lazyObservers.delete(id);
2914
+ }
2915
+ _teardownEchoListeners(id);
2916
+ _teardownPresence(id);
2917
+ _teardownShared(id);
2918
+ _optOps.delete(id);
2919
+ _pendingActionByComp.delete(id);
2920
+ }
2921
+ }
2922
+ }
2923
+
2924
+ // ── Real-time @on('echo:…') listeners ─────────────────────────────────────────
2925
+ //
2926
+ // A listener whose event name is `echo:CHANNEL,EVENT` (or echo-private: /
2927
+ // echo-presence:) subscribes to a broadcast channel via the global `window.Echo`
2928
+ // (the standard broadcast client the app configures). When the broadcast
2929
+ // arrives, the component's @on method runs server-side via a normal action frame —
2930
+ // so a broadcast updates the component exactly like any other event. No-op when
2931
+ // `window.Echo` is absent.
2932
+
2933
+ type EchoSub = { channel: string; kind: "" | "-private" | "-presence" };
2934
+ const _echoSubs = new Map<string, EchoSub[]>();
2935
+
2936
+ /** Parse `echo[-private|-presence]:channel,event` → parts, or null if not an echo listener. @internal */
2937
+ export function _parseEchoListener(
2938
+ name: string,
2939
+ ): { kind: EchoSub["kind"]; channel: string; event: string } | null {
2940
+ const m = /^echo(-private|-presence)?:/.exec(name);
2941
+ if (!m) return null;
2942
+ const kind = (m[1] ?? "") as EchoSub["kind"];
2943
+ const rest = name.slice(m[0].length);
2944
+ const ci = rest.lastIndexOf(","); // split on the LAST comma (channels may contain dots, not commas)
2945
+ if (ci === -1) return null;
2946
+ return { kind, channel: rest.slice(0, ci), event: rest.slice(ci + 1) };
2947
+ }
2948
+
2949
+ interface EchoChannel {
2950
+ listen(event: string, cb: (payload: unknown) => void): EchoChannel;
2951
+ here?(cb: (payload: unknown) => void): EchoChannel;
2952
+ joining?(cb: (payload: unknown) => void): EchoChannel;
2953
+ leaving?(cb: (payload: unknown) => void): EchoChannel;
2954
+ }
2955
+ interface EchoClient {
2956
+ channel(name: string): EchoChannel;
2957
+ private(name: string): EchoChannel;
2958
+ join(name: string): EchoChannel;
2959
+ leave(name: string): void;
2960
+ }
2961
+
2962
+ function _setupEchoListeners(comp: FlowComponent): void {
2963
+ const Echo = (window as unknown as { Echo?: EchoClient }).Echo;
2964
+ const listeners = comp.snapshot.memo.listeners;
2965
+ if (!Echo || !listeners) return;
2966
+
2967
+ const subs: EchoSub[] = [];
2968
+ for (const [name, method] of Object.entries(listeners)) {
2969
+ const parsed = _parseEchoListener(name);
2970
+ if (!parsed) continue;
2971
+ const { kind, channel, event } = parsed;
2972
+
2973
+ const ch =
2974
+ kind === "-private"
2975
+ ? Echo.private(channel)
2976
+ : kind === "-presence"
2977
+ ? Echo.join(channel)
2978
+ : Echo.channel(channel);
2979
+
2980
+ const cb = (payload: unknown) => _callAction(comp, method, [payload]);
2981
+ // Presence sub-events (here/joining/leaving) use their own methods; everything
2982
+ // else is a broadcast event delivered via .listen().
2983
+ if (kind === "-presence" && (event === "here" || event === "joining" || event === "leaving")) {
2984
+ ch[event]?.(cb);
2985
+ } else {
2986
+ ch.listen(event, cb);
2987
+ }
2988
+ subs.push({ channel, kind });
2989
+ }
2990
+ if (subs.length) _echoSubs.set(comp.id, subs);
2991
+ }
2992
+
2993
+ function _teardownEchoListeners(id: string): void {
2994
+ const Echo = (window as unknown as { Echo?: EchoClient }).Echo;
2995
+ const subs = _echoSubs.get(id);
2996
+ if (Echo && subs)
2997
+ for (const s of subs) {
2998
+ try {
2999
+ Echo.leave(s.channel);
3000
+ } catch {
3001
+ /* ignore */
3002
+ }
3003
+ }
3004
+ _echoSubs.delete(id);
3005
+ }
3006
+
3007
+ // ── @presence channels ────────────────────────────────────────────────────────
3008
+ // A component with @presence props carries its resolved channel(s) in memo.presence.
3009
+ // Join each presence channel and, on here/joining/leaving, dispatch `$presence` so the
3010
+ // server refills the member list and re-renders. Cursors/ephemeral state ride the same
3011
+ // channel via Echo whispers (see _presenceChannelFor / whisper helpers below).
3012
+
3013
+ interface PresenceEchoChannel extends EchoChannel {
3014
+ whisper?(event: string, data: unknown): PresenceEchoChannel;
3015
+ listenForWhisper?(event: string, cb: (data: unknown) => void): PresenceEchoChannel;
3016
+ }
3017
+ const _presenceChannels = new Map<string, string[]>(); // componentId → joined channels
3018
+
3019
+ function _setupPresence(comp: FlowComponent): void {
3020
+ const Echo = (window as unknown as { Echo?: EchoClient }).Echo;
3021
+ const presence = comp.snapshot.memo.presence;
3022
+ if (!Echo || !presence || presence.length === 0) return;
3023
+
3024
+ const channels: string[] = [];
3025
+ for (const { channel } of presence) {
3026
+ const ch = Echo.join(channel);
3027
+ const refresh = (): void => _callAction(comp, "$presence", []);
3028
+ ch.here?.(refresh);
3029
+ ch.joining?.(refresh);
3030
+ ch.leaving?.(refresh);
3031
+ channels.push(channel);
3032
+ }
3033
+ if (channels.length) _presenceChannels.set(comp.id, channels);
3034
+ }
3035
+
3036
+ function _teardownPresence(id: string): void {
3037
+ const Echo = (window as unknown as { Echo?: EchoClient }).Echo;
3038
+ const channels = _presenceChannels.get(id);
3039
+ if (Echo && channels)
3040
+ for (const c of channels) {
3041
+ try {
3042
+ Echo.leave(c);
3043
+ } catch {
3044
+ /* ignore */
3045
+ }
3046
+ }
3047
+ _presenceChannels.delete(id);
3048
+ }
3049
+
3050
+ /** The first presence channel joined for a component (for cursor/ephemeral whispers). */
3051
+ function _presenceChannelFor(comp: FlowComponent): string | null {
3052
+ return _presenceChannels.get(comp.id)?.[0] ?? null;
3053
+ }
3054
+
3055
+ /**
3056
+ * Broadcast ephemeral state (e.g. a cursor position) to the other members of the
3057
+ * component's presence channel — client-only, never hits the server or the DB. Pair with
3058
+ * `onWhisper`. No-op without `window.Echo` or a presence channel.
3059
+ */
3060
+ function _whisper(comp: FlowComponent, event: string, data: unknown): void {
3061
+ const Echo = (window as unknown as { Echo?: EchoClient }).Echo;
3062
+ const channel = _presenceChannelFor(comp);
3063
+ if (!Echo || !channel) return;
3064
+ (Echo.join(channel) as PresenceEchoChannel).whisper?.(event, data);
3065
+ }
3066
+
3067
+ /** Listen for peers' whispers on the component's presence channel (e.g. cursor moves). */
3068
+ function _onWhisper(comp: FlowComponent, event: string, cb: (data: unknown) => void): void {
3069
+ const Echo = (window as unknown as { Echo?: EchoClient }).Echo;
3070
+ const channel = _presenceChannelFor(comp);
3071
+ if (!Echo || !channel) return;
3072
+ (Echo.join(channel) as PresenceEchoChannel).listenForWhisper?.(event, cb);
3073
+ }
3074
+
3075
+ // ── @task cancellation ─────────────────────────────────────────────────────────
3076
+ // Cancel a running @task. The task's action still occupies the component's serial send queue
3077
+ // (its final patch hasn't arrived), so the cancel frame is sent OUT OF BAND — straight down the
3078
+ // socket, bypassing the queue — and the server handles `$cancel` before hydrate/dispatch,
3079
+ // aborting the task's AbortController. No-op when the socket isn't open.
3080
+
3081
+ function _cancelTask(comp: FlowComponent): void {
3082
+ const cancel = { type: "call", component: comp.id, method: "$cancel" };
3083
+ if (_ws && _ws.readyState === WebSocket.OPEN) {
3084
+ _ws.send(JSON.stringify(cancel));
3085
+ } else if (_httpMode) {
3086
+ // Out-of-band over HTTP (a separate POST); the server aborts the running task's controller.
3087
+ void fetch("/__flow/http", {
3088
+ method: "POST",
3089
+ headers: { "Content-Type": "application/json" },
3090
+ credentials: "include",
3091
+ body: JSON.stringify(cancel),
3092
+ }).catch(() => {});
3093
+ }
3094
+ }
3095
+
3096
+ // ── @shared channels ───────────────────────────────────────────────────────────
3097
+ // A component with @shared props carries its resolved channel(s) in memo.shared. Join each
3098
+ // channel and, on the `flow:shared` broadcast, dispatch `$shared` so the server re-reads
3099
+ // the room store and re-renders — every subscriber converges to the same state. Shared and
3100
+ // presence channels are joined the same way (Echo.join), so one room channel can carry both.
3101
+
3102
+ const SHARED_EVENT = "flow:shared";
3103
+ const _sharedChannels = new Map<string, string[]>(); // componentId → joined channels
3104
+
3105
+ function _setupShared(comp: FlowComponent): void {
3106
+ const Echo = (window as unknown as { Echo?: EchoClient }).Echo;
3107
+ const shared = comp.snapshot.memo.shared;
3108
+ if (!Echo || !shared || shared.length === 0) return;
3109
+
3110
+ const channels: string[] = [];
3111
+ for (const { channel } of shared) {
3112
+ const converge = (): void => _callAction(comp, "$shared", []);
3113
+ Echo.join(channel).listen(SHARED_EVENT, converge);
3114
+ channels.push(channel);
3115
+ }
3116
+ if (channels.length) _sharedChannels.set(comp.id, channels);
3117
+ }
3118
+
3119
+ function _teardownShared(id: string): void {
3120
+ const Echo = (window as unknown as { Echo?: EchoClient }).Echo;
3121
+ const channels = _sharedChannels.get(id);
3122
+ if (Echo && channels)
3123
+ for (const c of channels) {
3124
+ try {
3125
+ Echo.leave(c);
3126
+ } catch {
3127
+ /* ignore */
3128
+ }
3129
+ }
3130
+ _sharedChannels.delete(id);
3131
+ }
3132
+
3133
+ // ── @expose write guard ───────────────────────────────────────────────────────
3134
+
3135
+ /** True only for @expose properties (in snapshot, not @locked). */
3136
+ function _isWritable(comp: FlowComponent, key: string): boolean {
3137
+ const entry = comp.snapshot.data[key];
3138
+ return !!(entry && !entry[1]?.["locked"]);
3139
+ }
3140
+
3141
+ // ── Alpine magic registration ─────────────────────────────────────────────────
3142
+
3143
+ /**
3144
+ * Register $flow and $errors Alpine magic helpers.
3145
+ * Called from index.ts with the Alpine instance before Alpine.start().
3146
+ *
3147
+ * $flow mirrors Livewire's $wire API:
3148
+ *
3149
+ * $flow.method(args) — call an @expose method
3150
+ * $flow.prop — read a reactive property
3151
+ * $flow.prop = value — write locally (sent with next action)
3152
+ *
3153
+ * $flow.$refresh() — re-render the component
3154
+ * $flow.$commit() — alias for $refresh
3155
+ * $flow.$el — root DOM element
3156
+ * $flow.$id — component id
3157
+ * $flow.$name — component class name
3158
+ * $flow.$get('prop') — explicit property read
3159
+ * $flow.$set('prop', val) — write + immediately sync to server
3160
+ * $flow.$toggle('prop') — toggle boolean + immediately sync
3161
+ * $flow.$call('m', ...args) — explicit method call
3162
+ * $flow.$dispatch('evt', {}) — broadcast flow:evt on window
3163
+ * $flow.$on('evt', fn) — listen for flow:evt on window
3164
+ * $flow.$watch('prop', fn) — call fn(newVal, oldVal) on server patch
3165
+ */
3166
+ export function registerFlowMagic(alpine: AlpineType): void {
3167
+ const _gelBuild = (el: Element) => {
3168
+ const root = el.closest<HTMLElement>("[data-flow-root]");
3169
+ const id = root?.dataset["flowId"];
3170
+ const comp = id ? _components.get(id) : undefined;
3171
+
3172
+ return new Proxy({} as Record<string | symbol, unknown>, {
3173
+ get(_t, key: string | symbol): unknown {
3174
+ if (typeof key !== "string") return undefined;
3175
+
3176
+ // Bare client-magic aliases: in JSX you write `this.dispatch(...)` /
3177
+ // `this.parent.x()` / `this.refresh()` / `this.store(...)` (typesafe, mirrors
3178
+ // the server methods); `this.` → `$flow.` makes that `$flow.dispatch`, etc.
3179
+ // Normalise those bare names to their `$`-prefixed magics here.
3180
+ if (_MAGIC_ALIASES.has(key)) key = "$" + key;
3181
+
3182
+ // ── Built-in $ helpers ─────────────────────────────────────────────
3183
+ if (key === "$el") return comp?.rootEl;
3184
+ if (key === "$id") return comp?.id;
3185
+ if (key === "$name") return comp?.name;
3186
+
3187
+ if (key === "$refresh" || key === "$commit") {
3188
+ return () => {
3189
+ if (comp) _callAction(comp, "$refresh", []);
3190
+ };
3191
+ }
3192
+
3193
+ if (key === "$get") {
3194
+ return (prop: string) => comp?.reactive[prop];
3195
+ }
3196
+
3197
+ if (key === "$set") {
3198
+ return (prop: string, val: unknown) => {
3199
+ if (!comp || !_isWritable(comp, prop)) return;
3200
+ // reactive first (see set-trap note): ephemeral is the proxy's raw target.
3201
+ comp.reactive[prop] = val;
3202
+ comp.ephemeral[prop] = val;
3203
+ _updateDirty(comp);
3204
+ _syncDeclarative(comp);
3205
+ _syncModelInputs(comp);
3206
+ _callAction(comp, "$set", [prop, val]);
3207
+ };
3208
+ }
3209
+
3210
+ if (key === "$toggle") {
3211
+ return (prop: string) => {
3212
+ if (!comp || !_isWritable(comp, prop)) return;
3213
+ const next = !comp.reactive[prop];
3214
+ // reactive first (see set-trap note): ephemeral is the proxy's raw target.
3215
+ comp.reactive[prop] = next;
3216
+ comp.ephemeral[prop] = next;
3217
+ _updateDirty(comp);
3218
+ _syncDeclarative(comp);
3219
+ _syncModelInputs(comp);
3220
+ _callAction(comp, "$set", [prop, next]);
3221
+ };
3222
+ }
3223
+
3224
+ if (key === "$call") {
3225
+ return (method: string, ...args: unknown[]) => {
3226
+ if (comp) _callAction(comp, method, args);
3227
+ };
3228
+ }
3229
+
3230
+ // $whisper('cursor', {x,y}) / $onWhisper('cursor', cb) — broadcast/receive ephemeral
3231
+ // state to peers on this component's @presence channel (client-only; never hits the
3232
+ // server or the DB). Ideal for cursors and typing indicators.
3233
+ if (key === "$whisper") {
3234
+ return (event: string, data: unknown) => {
3235
+ if (comp) _whisper(comp, event, data);
3236
+ };
3237
+ }
3238
+ if (key === "$onWhisper") {
3239
+ return (event: string, fn: (data: unknown) => void) => {
3240
+ if (comp) _onWhisper(comp, event, fn);
3241
+ };
3242
+ }
3243
+
3244
+ // $cancel() — stop the component's running @task (sent out-of-band; the server aborts
3245
+ // the task's AbortSignal, which the task observes via this.signal / this.cancelled).
3246
+ if (key === "$cancel") {
3247
+ return () => {
3248
+ if (comp) _cancelTask(comp);
3249
+ };
3250
+ }
3251
+
3252
+ // $appendOptimistic(prop, item) / $removeOptimistic(prop, match) — optimistic list
3253
+ // mutations: apply to the reactive store instantly (a reactive <For>/x-for list shows them
3254
+ // at once), survive interim patches, reconcile/roll back when the owning action resolves.
3255
+ if (key === "$appendOptimistic") {
3256
+ return (prop: string, item: unknown) => {
3257
+ if (comp) _appendOptimistic(comp, prop, item);
3258
+ };
3259
+ }
3260
+ if (key === "$removeOptimistic") {
3261
+ return (prop: string, match: (item: unknown) => boolean) => {
3262
+ if (comp) _removeOptimistic(comp, prop, match);
3263
+ };
3264
+ }
3265
+
3266
+ // $store — the "flow" Alpine store (app-wide, client-only UI state). It's a real Alpine
3267
+ // store, so reads inside a binding track and writes re-render with no round-trip. Declare
3268
+ // its shape with defineStore(); see store.ts.
3269
+ if (key === "$store") return clientStore();
3270
+
3271
+ // $dispatch('event', {data}) — fire an event to every component on the page
3272
+ // (Alpine x-on listeners AND server @on listeners), matching this.dispatch().
3273
+ if (key === "$dispatch") {
3274
+ return (name: string, data: Record<string, unknown> = {}) => {
3275
+ _emitEvent(name, data);
3276
+ };
3277
+ }
3278
+
3279
+ // $dispatchTo('ComponentName', 'event', {data}) — only that component class.
3280
+ if (key === "$dispatchTo") {
3281
+ return (toName: string, name: string, data: Record<string, unknown> = {}) => {
3282
+ _emitEvent(name, data, { toName });
3283
+ };
3284
+ }
3285
+
3286
+ // $dispatchSelf('event', {data}) — only this component (no bubbling to others).
3287
+ if (key === "$dispatchSelf") {
3288
+ return (name: string, data: Record<string, unknown> = {}) => {
3289
+ _emitEvent(name, data, { selfId: comp?.id });
3290
+ };
3291
+ }
3292
+
3293
+ if (key === "$on") {
3294
+ return (name: string, fn: (detail: unknown) => void) => {
3295
+ const handler = (e: Event) => fn((e as CustomEvent).detail);
3296
+ window.addEventListener(`flow:${name}`, handler);
3297
+ };
3298
+ }
3299
+
3300
+ // $parent — call actions on / read state from the nearest ancestor component:
3301
+ // $flow.$parent.showCreatePostForm() $flow.$parent.someExposedProp
3302
+ if (key === "$parent") {
3303
+ const parentEl = comp?.rootEl.parentElement?.closest<HTMLElement>("[data-flow-root]");
3304
+ const parentId = parentEl?.dataset["flowId"];
3305
+ const parent = parentId ? _components.get(parentId) : undefined;
3306
+ return new Proxy({} as Record<string | symbol, unknown>, {
3307
+ get(_pt, pkey: string | symbol): unknown {
3308
+ if (typeof pkey !== "string" || !parent) return undefined;
3309
+ // Exposed/locked property → reactive value; anything else → server action.
3310
+ if (pkey in parent.snapshot.data) return parent.reactive[pkey];
3311
+ return (...args: unknown[]) => _callAction(parent, pkey, args);
3312
+ },
3313
+ });
3314
+ }
3315
+
3316
+ if (key === "$watch") {
3317
+ return (prop: string, fn: WatchFn) => {
3318
+ if (!comp) return;
3319
+ let compMap = _watchers.get(comp.id);
3320
+ if (!compMap) {
3321
+ compMap = new Map();
3322
+ _watchers.set(comp.id, compMap);
3323
+ }
3324
+ let propSet = compMap.get(prop);
3325
+ if (!propSet) {
3326
+ propSet = new Set();
3327
+ compMap.set(prop, propSet);
3328
+ }
3329
+ propSet.add(fn);
3330
+ };
3331
+ }
3332
+
3333
+ // ── Property read: key exists in snapshot data → reactive value ────
3334
+ if (comp && key in comp.snapshot.data) {
3335
+ return comp.reactive[key];
3336
+ }
3337
+
3338
+ // ── Client-only URL helpers — after snapshot so a same-named prop wins.
3339
+ // `this.currentUrl(...)` / `this.navigateCurrent(...)` compile to these.
3340
+ if (key === "currentUrl") return _currentUrl;
3341
+ if (key === "navigateCurrent") return _navigateCurrent;
3342
+
3343
+ // ── Method call: anything else is treated as a server action ───────
3344
+ return (...args: unknown[]) => {
3345
+ if (comp) _callAction(comp, key, args);
3346
+ };
3347
+ },
3348
+
3349
+ set(_t, key: string | symbol, value: unknown): boolean {
3350
+ if (typeof key !== "string" || !comp || !_isWritable(comp, key)) return true;
3351
+ // Write through the reactive proxy FIRST. `reactive` is Alpine.reactive(ephemeral),
3352
+ // so ephemeral is its raw target — assigning ephemeral[key] beforehand would make
3353
+ // the proxy's setter see oldValue === value and skip the reactive trigger, leaving
3354
+ // native Alpine bindings (x-text/:class) stale until the next server patch.
3355
+ comp.reactive[key] = value;
3356
+ comp.ephemeral[key] = value;
3357
+ _updateDirty(comp);
3358
+ _syncDeclarative(comp);
3359
+ // flow:model inputs aren't Alpine-bound, so push the new value into them explicitly.
3360
+ _syncModelInputs(comp);
3361
+ return true;
3362
+ },
3363
+ });
3364
+ };
3365
+ alpine.magic("flow", _gelBuild);
3366
+ _gelMagicBuilder = _gelBuild as (el: Element) => unknown;
3367
+
3368
+ // $errors — per-component validation error bag
3369
+ alpine.magic("errors", (el: Element) => {
3370
+ const root = el.closest<HTMLElement>("[data-flow-root]");
3371
+ const id = root?.dataset["flowId"] ?? "";
3372
+
3373
+ return new Proxy({} as Record<string, unknown>, {
3374
+ get(_target, field: string | symbol) {
3375
+ if (typeof field !== "string") return undefined;
3376
+ const bag = _getErrors(id);
3377
+
3378
+ if (field === "has") return (f: string) => !!(bag[f] && bag[f]!.length > 0);
3379
+ if (field === "first") return (f: string) => bag[f]?.[0] ?? "";
3380
+ if (field === "get") return (f: string) => bag[f] ?? [];
3381
+ if (field === "all") return () => bag;
3382
+ if (field === "any")
3383
+ return () => Object.keys(bag).some((k) => (bag[k] as string[]).length > 0);
3384
+ if (field === "clear")
3385
+ return (f?: string) => {
3386
+ if (f) {
3387
+ delete bag[f];
3388
+ } else {
3389
+ _errors.set(id, {});
3390
+ }
3391
+ };
3392
+
3393
+ // Direct field access: $errors.email → first message string or ''
3394
+ return (bag[field] as string[] | undefined)?.[0] ?? "";
3395
+ },
3396
+ });
3397
+ });
3398
+ }
3399
+
3400
+ // ── Modal Escape-to-close (the <Modal> component) ─────────────────────────────
3401
+ //
3402
+ // A <Modal show={this.open}> emits `data-flow-modal="open"` carrying the bound
3403
+ // @expose prop name. Pressing Escape closes the top-most visible modal by setting
3404
+ // that prop to false (locally + synced to the server), mirroring the backdrop /
3405
+ // close-button click. Modals with no bound prop (fully custom onClose) opt out.
3406
+
3407
+ function _setupModalEscape(): void {
3408
+ document.addEventListener("keydown", (e) => {
3409
+ if (e.key !== "Escape") return;
3410
+ const open = Array.from(document.querySelectorAll<HTMLElement>("[data-flow-modal]")).filter(
3411
+ (el) => getComputedStyle(el).display !== "none",
3412
+ );
3413
+ const top = open[open.length - 1];
3414
+ if (!top) return;
3415
+ const name = top.dataset["flowModal"];
3416
+ if (!name) return;
3417
+ const comp = findComponentByEl(top);
3418
+ if (!comp || !_isWritable(comp, name)) return;
3419
+ // Close locally only — NO server round-trip, matching the backdrop / × paths
3420
+ // (which use the $flow set trap). reactive first (see set-trap note): ephemeral
3421
+ // is the proxy's raw target. The dirty-tracked change rides with the next action.
3422
+ comp.reactive[name] = false;
3423
+ comp.ephemeral[name] = false;
3424
+ _updateDirty(comp);
3425
+ _syncDeclarative(comp);
3426
+ _syncModelInputs(comp);
3427
+ });
3428
+ }
3429
+
3430
+ // ── Bridge entry point ────────────────────────────────────────────────────────
3431
+
3432
+ /**
3433
+ * Dev-only: an error thrown during the initial GET render is embedded by the server as a JSON
3434
+ * `<script id="flow-boot-error">` (only under the dev worker). Read it on init and raise the same
3435
+ * error overlay an action throw would — so the very first paint failing lands in the overlay too.
3436
+ */
3437
+ function _checkBootError(): void {
3438
+ const el = document.getElementById("flow-boot-error");
3439
+ if (!el) return;
3440
+ try {
3441
+ const info = JSON.parse(el.textContent ?? "{}") as {
3442
+ message?: string;
3443
+ name?: string;
3444
+ stack?: string;
3445
+ action?: string;
3446
+ component?: string;
3447
+ };
3448
+ if (info.message) showErrorOverlay({ ...info, message: info.message });
3449
+ } catch {
3450
+ /* malformed boot-error payload — ignore */
3451
+ }
3452
+ }
3453
+
3454
+ export function initBridge(): void {
3455
+ _injectFlowStyles();
3456
+ _checkBootError(); // dev-only: an initial-render throw is embedded on the page → show the overlay
3457
+ _scanComponents();
3458
+ _processHead(); // hoist any SSR <Head> content into document.head
3459
+ _connect();
3460
+ _setupEventDelegation();
3461
+ _setupNavigationLinks();
3462
+ _setupModalEscape();
3463
+ }