@zerotal/flow 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +61 -0
- package/LICENSE +21 -0
- package/README.md +148 -0
- package/package.json +84 -0
- package/src/Component.ts +1498 -0
- package/src/Form.ts +189 -0
- package/src/Layout.ts +47 -0
- package/src/augment.ts +94 -0
- package/src/client/FlowClientError.ts +12 -0
- package/src/client/FlowComponent.ts +96 -0
- package/src/client/alpinejs-morph.d.ts +5 -0
- package/src/client/bridge.ts +3463 -0
- package/src/client/confirm.ts +338 -0
- package/src/client/cspAdapter.ts +79 -0
- package/src/client/cspEvaluator.ts +494 -0
- package/src/client/errorOverlay.ts +124 -0
- package/src/client/headless.ts +948 -0
- package/src/client/index.csp.ts +47 -0
- package/src/client/index.ts +56 -0
- package/src/client/optimistic.ts +52 -0
- package/src/client/timeline.ts +160 -0
- package/src/client/url.ts +51 -0
- package/src/commands/MakeFlowCommand.ts +290 -0
- package/src/compiler/cache.ts +101 -0
- package/src/compiler/directives.ts +107 -0
- package/src/compiler/helpers.ts +26 -0
- package/src/compiler/index.ts +172 -0
- package/src/compiler/transform.ts +2070 -0
- package/src/components.ts +1465 -0
- package/src/config.ts +52 -0
- package/src/decorators.ts +978 -0
- package/src/dehydrate.ts +333 -0
- package/src/durable.ts +243 -0
- package/src/events.ts +118 -0
- package/src/frameworkEvents.ts +40 -0
- package/src/headless.ts +1147 -0
- package/src/index.ts +267 -0
- package/src/jsx-dev-runtime.ts +14 -0
- package/src/jsx-runtime.ts +1272 -0
- package/src/mixins.ts +199 -0
- package/src/observability.ts +167 -0
- package/src/pagination.ts +300 -0
- package/src/presence.ts +81 -0
- package/src/provider/FlowProvider.ts +1401 -0
- package/src/registry.ts +118 -0
- package/src/renderContext.ts +55 -0
- package/src/router.ts +401 -0
- package/src/shared.ts +226 -0
- package/src/store.ts +116 -0
- package/src/synths/CarbonIntervalSynth.ts +22 -0
- package/src/synths/CarbonSynth.ts +20 -0
- package/src/synths/CollectionSynth.ts +62 -0
- package/src/synths/DateSynth.ts +24 -0
- package/src/synths/ModelSynth.ts +78 -0
- package/src/synths/TemporaryUploadedFileSynth.ts +19 -0
- package/src/synths/index.ts +148 -0
- package/src/testing.ts +361 -0
- package/src/types.ts +315 -0
- package/src/uploads/FileUploads.ts +94 -0
- package/src/uploads/TemporaryUploadedFile.ts +274 -0
- package/src/uploads/sign.ts +18 -0
- package/src/urlSafety.ts +0 -0
- package/src/utils.ts +216 -0
- package/src/validation.ts +55 -0
|
@@ -0,0 +1,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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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
|
+
}
|