@zerotal/flow 1.7.0 → 1.7.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +176 -0
- package/README.md +1 -1
- package/api-surface.md +10 -6
- package/package.json +6 -5
- package/src/Component.ts +128 -50
- package/src/browser/FlowBrowser.ts +7 -2
- package/src/client/bridge.ts +353 -83
- package/src/client/framePanes.ts +229 -0
- package/src/client/headless.ts +7 -1
- package/src/client/timeline.ts +12 -0
- package/src/components.ts +13 -2
- package/src/decorators.ts +104 -14
- package/src/dehydrate.ts +51 -2
- package/src/durable.ts +5 -2
- package/src/events.ts +3 -3
- package/src/index.ts +1 -0
- package/src/jsx-runtime.ts +13 -0
- package/src/observability.ts +28 -1
- package/src/registry.ts +6 -2
- package/src/router.ts +7 -1
- package/src/synths/CollectionSynth.ts +17 -15
- package/src/synths/ModelSynth.ts +335 -35
package/src/client/bridge.ts
CHANGED
|
@@ -34,6 +34,11 @@ import { confirmDialog, type ConfirmDialogOptions } from "./confirm.ts";
|
|
|
34
34
|
import { showErrorOverlay } from "./errorOverlay.ts";
|
|
35
35
|
import { applyAppend, applyRemove, type OptOp } from "./optimistic.ts";
|
|
36
36
|
import { clientStore } from "../store.ts";
|
|
37
|
+
// The `/Socket` subpath, not the package root: the root also exports
|
|
38
|
+
// `ClientProvider`, which reaches @zerotal/core's CLI modules — one of which
|
|
39
|
+
// does `await import("bun")`, a hard error in a browser bundle before
|
|
40
|
+
// tree-shaking can drop it.
|
|
41
|
+
import { Socket as BundledSocket } from "@zerotal/client/Socket";
|
|
37
42
|
import { route } from "@zerotal/core/routes";
|
|
38
43
|
import { URL_ATTRIBUTES, sanitizeUrl } from "../urlSafety.ts";
|
|
39
44
|
import {
|
|
@@ -43,6 +48,11 @@ import {
|
|
|
43
48
|
setTimelineApplier,
|
|
44
49
|
} from "./timeline.ts";
|
|
45
50
|
import * as _timeline from "./timeline.ts";
|
|
51
|
+
import {
|
|
52
|
+
framePanes as _framePanes,
|
|
53
|
+
renderFramePanes as _renderFramePanes,
|
|
54
|
+
type FrameServerCost as _ServerCost,
|
|
55
|
+
} from "./framePanes.ts";
|
|
46
56
|
|
|
47
57
|
// CSP-safe mode: set by the CSP client entry. When on, bridge-managed bindings
|
|
48
58
|
// use the eval-free evaluateCsp() instead of `new Function`.
|
|
@@ -712,14 +722,19 @@ function _sendCall(
|
|
|
712
722
|
.then(
|
|
713
723
|
() =>
|
|
714
724
|
new Promise<void>((resolve) => {
|
|
725
|
+
const updates = fixedUpdates ?? _pendingUpdates(comp);
|
|
715
726
|
const frame = {
|
|
716
727
|
type: "call",
|
|
717
728
|
component: compId,
|
|
718
729
|
method,
|
|
719
730
|
args,
|
|
720
|
-
updates
|
|
731
|
+
updates,
|
|
721
732
|
snapshot: comp.snapshot,
|
|
722
733
|
};
|
|
734
|
+
// Here is where the client writes riding along with this call are
|
|
735
|
+
// finally resolved, so it is the only place the panel can learn them.
|
|
736
|
+
const pending = _pendingActionByComp.get(compId);
|
|
737
|
+
if (pending && pending.method === method) pending.updates = updates;
|
|
723
738
|
// Safety net: never wedge the queue if a patch is dropped (server crash / HTTP error).
|
|
724
739
|
const armSafetyNet = (): void => {
|
|
725
740
|
setTimeout(() => {
|
|
@@ -1049,6 +1064,32 @@ function _dispatchFlash(detail: Record<string, unknown>): void {
|
|
|
1049
1064
|
// episode triggers at most one reload, while a later, genuine restart can recover again.
|
|
1050
1065
|
const _EXPIRY_RELOAD_KEY = "flow:reloaded-for-expired-component";
|
|
1051
1066
|
|
|
1067
|
+
/**
|
|
1068
|
+
* Why an error frame should be recovered by a reload, if it should.
|
|
1069
|
+
*
|
|
1070
|
+
* Pure and exported so the policy is testable without a DOM — the handler that
|
|
1071
|
+
* calls it needs `window`, `sessionStorage` and a live component map.
|
|
1072
|
+
*
|
|
1073
|
+
* `reload` is set by the server when the snapshot no longer belongs to the
|
|
1074
|
+
* session that is asking, which is what signing out in another tab looks like
|
|
1075
|
+
* from here. The page's state belongs to who you *were*, so no patch can
|
|
1076
|
+
* succeed and reloading is the only honest recovery — the fresh request meets
|
|
1077
|
+
* auth middleware and lands wherever a signed-out visitor belongs.
|
|
1078
|
+
*
|
|
1079
|
+
* Both cases share the one-shot guard: a reload that does not fix the problem
|
|
1080
|
+
* must not reload again.
|
|
1081
|
+
*/
|
|
1082
|
+
export function _reloadReasonFor(
|
|
1083
|
+
frame: { message?: string; reload?: boolean },
|
|
1084
|
+
alreadyReloaded: boolean,
|
|
1085
|
+
): "session-changed" | "unknown-component" | null {
|
|
1086
|
+
if (alreadyReloaded) return null;
|
|
1087
|
+
// The server's explicit instruction outranks any inference from the message.
|
|
1088
|
+
if (frame.reload) return "session-changed";
|
|
1089
|
+
if (frame.message === "Unknown component") return "unknown-component";
|
|
1090
|
+
return null;
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1052
1093
|
function _reloadedForExpiry(): boolean {
|
|
1053
1094
|
try {
|
|
1054
1095
|
return sessionStorage.getItem(_EXPIRY_RELOAD_KEY) === "1";
|
|
@@ -1186,9 +1227,10 @@ function _enableTimeline(): void {
|
|
|
1186
1227
|
if (dt && typeof dt.register === "function") {
|
|
1187
1228
|
dt.register({
|
|
1188
1229
|
id: "flow-timeline",
|
|
1189
|
-
title: "
|
|
1230
|
+
title: "Flow",
|
|
1190
1231
|
badge: () => _timeline.getFrames().length || undefined,
|
|
1191
|
-
render: (el: HTMLElement) =>
|
|
1232
|
+
render: (el: HTMLElement, context?: { trace?: _TraceLike | null }) =>
|
|
1233
|
+
_renderTimelineInto(el, context?.trace ?? null),
|
|
1192
1234
|
});
|
|
1193
1235
|
_timeline.onTimelineChange(() => dt.refresh("flow-timeline"));
|
|
1194
1236
|
} else {
|
|
@@ -1207,9 +1249,90 @@ interface _DevtoolsRegistryLike {
|
|
|
1207
1249
|
refresh(id?: string): void;
|
|
1208
1250
|
}
|
|
1209
1251
|
|
|
1252
|
+
/**
|
|
1253
|
+
* The half of a devtools trace this panel reads.
|
|
1254
|
+
*
|
|
1255
|
+
* Structural rather than imported: Flow depends on no observer package, and an
|
|
1256
|
+
* app without devtools installed simply never passes one. Every field is optional
|
|
1257
|
+
* for the same reason — this is a shape read from another package's data.
|
|
1258
|
+
*/
|
|
1259
|
+
interface _TraceLike {
|
|
1260
|
+
statusCode?: number;
|
|
1261
|
+
durationMs?: number;
|
|
1262
|
+
channels?: Record<string, Array<Record<string, unknown>>>;
|
|
1263
|
+
queries?: Array<{ sql?: unknown; durationMs?: unknown; rowCount?: unknown }>;
|
|
1264
|
+
logs?: Array<{ level?: unknown; args?: unknown }>;
|
|
1265
|
+
exception?: { message?: unknown } | null;
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
/**
|
|
1269
|
+
* Server cost per frame, accumulated as traces arrive.
|
|
1270
|
+
*
|
|
1271
|
+
* A trace carries one action, and the panel follows the newest — so each render
|
|
1272
|
+
* can bind at most the frame that just happened. Keeping what earlier renders
|
|
1273
|
+
* bound is what lets a frame from four clicks ago still show what it cost, rather
|
|
1274
|
+
* than only ever the selected one being annotated.
|
|
1275
|
+
*/
|
|
1276
|
+
const _frameCost = new Map<number, _ServerCost>();
|
|
1277
|
+
|
|
1278
|
+
/** Attach the selected trace's action to the frame it produced, once. */
|
|
1279
|
+
function _bindServerCost(
|
|
1280
|
+
trace: _TraceLike | null,
|
|
1281
|
+
frames: readonly _timeline.TimelineFrame[],
|
|
1282
|
+
): void {
|
|
1283
|
+
for (const entry of trace?.channels?.["flow"] ?? []) {
|
|
1284
|
+
const component = entry["component"];
|
|
1285
|
+
const action = entry["action"];
|
|
1286
|
+
const durationMs = entry["durationMs"];
|
|
1287
|
+
if (typeof component !== "string" || typeof action !== "string") continue;
|
|
1288
|
+
if (typeof durationMs !== "number") continue;
|
|
1289
|
+
|
|
1290
|
+
// The newest matching frame that has no cost yet: an action produces its
|
|
1291
|
+
// frame before the trace reaches the panel, and re-rendering the same trace
|
|
1292
|
+
// must not walk the binding back through older frames of the same name.
|
|
1293
|
+
for (let i = frames.length - 1; i >= 0; i--) {
|
|
1294
|
+
const frame = frames[i]!;
|
|
1295
|
+
if (frame.compId !== component || frame.action !== action) continue;
|
|
1296
|
+
if (_frameCost.has(frame.seq)) break;
|
|
1297
|
+
const ip = entry["ip"];
|
|
1298
|
+
_frameCost.set(frame.seq, {
|
|
1299
|
+
durationMs,
|
|
1300
|
+
ip: typeof ip === "string" ? ip : null,
|
|
1301
|
+
statusCode: typeof trace?.statusCode === "number" ? trace.statusCode : null,
|
|
1302
|
+
// One action is one trace, so the whole trace is this action's server half.
|
|
1303
|
+
queries: (trace?.queries ?? []).map((q) => ({
|
|
1304
|
+
sql: String(q.sql ?? ""),
|
|
1305
|
+
durationMs: typeof q.durationMs === "number" ? q.durationMs : null,
|
|
1306
|
+
rowCount: typeof q.rowCount === "number" ? q.rowCount : null,
|
|
1307
|
+
})),
|
|
1308
|
+
logs: (trace?.logs ?? []).map((l) => ({
|
|
1309
|
+
level: String(l.level ?? "log"),
|
|
1310
|
+
text: Array.isArray(l.args) ? l.args.map((a) => String(a)).join(" ") : "",
|
|
1311
|
+
})),
|
|
1312
|
+
error:
|
|
1313
|
+
trace?.exception && typeof trace.exception.message === "string"
|
|
1314
|
+
? trace.exception.message
|
|
1315
|
+
: null,
|
|
1316
|
+
});
|
|
1317
|
+
break;
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
/** Which frames are expanded. Module state so a redraw does not collapse them. */
|
|
1323
|
+
const _expanded = new Set<number>();
|
|
1324
|
+
|
|
1325
|
+
/** The trace of the last draw, so toggling a row can redraw without waiting for one. */
|
|
1326
|
+
let _lastTrace: _TraceLike | null = null;
|
|
1327
|
+
|
|
1328
|
+
/** Which pane of a frame the reader last picked. See `activePane`. */
|
|
1329
|
+
let _framePref = "";
|
|
1330
|
+
|
|
1210
1331
|
/** Render the timeline into the Zerotal devtools content area (themed with its CSS vars/classes). */
|
|
1211
|
-
function _renderTimelineInto(el: HTMLElement): void {
|
|
1332
|
+
function _renderTimelineInto(el: HTMLElement, trace: _TraceLike | null = null): void {
|
|
1212
1333
|
const frames = _timeline.getFrames();
|
|
1334
|
+
_lastTrace = trace;
|
|
1335
|
+
_bindServerCost(trace, frames);
|
|
1213
1336
|
const compIds = [...new Set(frames.map((f) => f.compId))];
|
|
1214
1337
|
const curSeqs = new Set(compIds.map((id) => _timeline.currentSeq(id)));
|
|
1215
1338
|
const rewound = compIds.some((id) => !_timeline.isLive(id));
|
|
@@ -1228,13 +1351,30 @@ function _renderTimelineInto(el: HTMLElement): void {
|
|
|
1228
1351
|
const cur = curSeqs.has(f.seq);
|
|
1229
1352
|
const changed = f.changed.length ? f.changed.join(", ") : "—";
|
|
1230
1353
|
const time = new Date(f.ts).toLocaleTimeString();
|
|
1354
|
+
// A frame with no cost ran entirely in the browser — a client expression
|
|
1355
|
+
// never reached the server, and saying nothing is the honest rendering.
|
|
1356
|
+
const cost = _frameCost.get(f.seq);
|
|
1357
|
+
const server = cost
|
|
1358
|
+
? `<span class="dim" style="font-size:10px">server ${cost.durationMs}ms${cost.queries.length ? ` · ${cost.queries.length}q` : ""}</span>`
|
|
1359
|
+
: "";
|
|
1360
|
+
const open = _expanded.has(f.seq);
|
|
1361
|
+
const detail = open
|
|
1362
|
+
? `<div class="hdetail" style="margin-left:26px">` +
|
|
1363
|
+
_renderFramePanes(_framePanes(f, frames, _frameCost.get(f.seq) ?? null), _framePref) +
|
|
1364
|
+
`</div>`
|
|
1365
|
+
: "";
|
|
1231
1366
|
return (
|
|
1232
1367
|
`<div class="qrow" data-tl-seq="${f.seq}" style="cursor:pointer;display:flex;gap:8px;align-items:baseline${cur ? ";background:var(--card)" : ""}">` +
|
|
1368
|
+
// Its own hit area: the row time-travels, and expanding to read what
|
|
1369
|
+
// happened must not also rewind the page you are reading it from.
|
|
1370
|
+
`<span data-tl-exp="${f.seq}" title="${open ? "Collapse" : "What this action did"}" style="min-width:12px;cursor:pointer;user-select:none">${open ? "▾" : "▸"}</span>` +
|
|
1233
1371
|
`<span class="dim" style="min-width:30px">#${f.seq}</span>` +
|
|
1234
1372
|
`<span style="color:var(--purple);font-weight:700">${_escapeHtml(f.action)}</span>` +
|
|
1235
1373
|
`<span class="dim" style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${_escapeHtml(f.compName)} · ${_escapeHtml(changed)}</span>` +
|
|
1374
|
+
server +
|
|
1236
1375
|
`<span class="dim" style="font-size:10px">${time}</span>` +
|
|
1237
|
-
`</div>`
|
|
1376
|
+
`</div>` +
|
|
1377
|
+
detail
|
|
1238
1378
|
);
|
|
1239
1379
|
})
|
|
1240
1380
|
.join("")
|
|
@@ -1249,6 +1389,24 @@ function _renderTimelineInto(el: HTMLElement): void {
|
|
|
1249
1389
|
_timeline.resumeLive(id);
|
|
1250
1390
|
return;
|
|
1251
1391
|
}
|
|
1392
|
+
// A pane of the frame being read. First, since the strip sits inside the
|
|
1393
|
+
// detail the row opened and a click there must not also rewind the page.
|
|
1394
|
+
const pane = target.closest<HTMLElement>("[data-fsec]");
|
|
1395
|
+
if (pane?.dataset["fsec"]) {
|
|
1396
|
+
_framePref = pane.dataset["fsec"];
|
|
1397
|
+
_renderTimelineInto(el, _lastTrace);
|
|
1398
|
+
return;
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
// Checked before the row, since the toggle sits inside it.
|
|
1402
|
+
const toggle = target.closest<HTMLElement>("[data-tl-exp]");
|
|
1403
|
+
if (toggle?.dataset["tlExp"]) {
|
|
1404
|
+
const seq = Number(toggle.dataset["tlExp"]);
|
|
1405
|
+
if (_expanded.has(seq)) _expanded.delete(seq);
|
|
1406
|
+
else _expanded.add(seq);
|
|
1407
|
+
_renderTimelineInto(el, _lastTrace);
|
|
1408
|
+
return;
|
|
1409
|
+
}
|
|
1252
1410
|
const row = target.closest<HTMLElement>("[data-tl-seq]");
|
|
1253
1411
|
if (row?.dataset["tlSeq"]) _timeline.jumpTo(Number(row.dataset["tlSeq"]));
|
|
1254
1412
|
};
|
|
@@ -1436,6 +1594,7 @@ async function _handleServerFrame(
|
|
|
1436
1594
|
dataRemoved?: string[];
|
|
1437
1595
|
actionError?: boolean;
|
|
1438
1596
|
dev?: boolean;
|
|
1597
|
+
reload?: boolean;
|
|
1439
1598
|
},
|
|
1440
1599
|
): Promise<void> {
|
|
1441
1600
|
if (frame.type === "ready") {
|
|
@@ -1515,12 +1674,21 @@ async function _handleServerFrame(
|
|
|
1515
1674
|
// Time-travel: record this applied patch as a timeline frame (final patches only — mid-@task
|
|
1516
1675
|
// partials would flood the timeline). No-op unless recording is enabled (dev).
|
|
1517
1676
|
if (!frame.partial && isTimelineEnabled()) {
|
|
1677
|
+
const dispatched = _takeLastDispatch(component);
|
|
1518
1678
|
_recordTimelineFrame({
|
|
1519
1679
|
compId: component,
|
|
1520
1680
|
compName: comp.name,
|
|
1521
|
-
action:
|
|
1681
|
+
action: dispatched.action,
|
|
1522
1682
|
snapshot: comp.snapshot,
|
|
1523
1683
|
html: comp.rootEl.outerHTML,
|
|
1684
|
+
...(dispatched.sent
|
|
1685
|
+
? {
|
|
1686
|
+
sent: {
|
|
1687
|
+
args: dispatched.sent.args,
|
|
1688
|
+
...(dispatched.sent.updates ? { updates: dispatched.sent.updates } : {}),
|
|
1689
|
+
},
|
|
1690
|
+
}
|
|
1691
|
+
: {}),
|
|
1524
1692
|
});
|
|
1525
1693
|
}
|
|
1526
1694
|
|
|
@@ -1594,11 +1762,14 @@ async function _handleServerFrame(
|
|
|
1594
1762
|
// fresh document re-renders and re-registers every component server-side, then
|
|
1595
1763
|
// re-hydrates the client. A one-shot guard (cleared once patches flow again, below)
|
|
1596
1764
|
// prevents a reload loop if the reload doesn't resolve it.
|
|
1597
|
-
|
|
1765
|
+
const reloadReason = _reloadReasonFor(frame, _reloadedForExpiry());
|
|
1766
|
+
if (reloadReason) {
|
|
1598
1767
|
_markReloadedForExpiry();
|
|
1599
1768
|
console.warn(
|
|
1600
|
-
|
|
1601
|
-
`
|
|
1769
|
+
reloadReason === "session-changed"
|
|
1770
|
+
? `[Flow] ${frame.message ?? "The session changed"} — reloading the page.`
|
|
1771
|
+
: `[Flow] Component "${frame.component}" is no longer registered on the server ` +
|
|
1772
|
+
`(likely a server restart) — reloading the page to recover.`,
|
|
1602
1773
|
);
|
|
1603
1774
|
window.location.reload();
|
|
1604
1775
|
return;
|
|
@@ -1687,6 +1858,16 @@ function _syncModelInputs(comp: FlowComponent): void {
|
|
|
1687
1858
|
el.checked = el.value === String(serverVal ?? "");
|
|
1688
1859
|
return;
|
|
1689
1860
|
}
|
|
1861
|
+
// A file input's value belongs to the user agent: assigning anything but "" throws
|
|
1862
|
+
// InvalidStateError, and that throw escapes the frame handler — the morph never runs
|
|
1863
|
+
// and the action's ack never resolves, wedging the component's queue for good. The
|
|
1864
|
+
// server side of the binding is an upload reference, not a filename, so there is
|
|
1865
|
+
// nothing to write back; only the server *clearing* it is worth mirroring, so the
|
|
1866
|
+
// same file can be picked again after a remove.
|
|
1867
|
+
if (el instanceof HTMLInputElement && el.type === "file") {
|
|
1868
|
+
if (serverVal === null || serverVal === undefined) el.value = "";
|
|
1869
|
+
return;
|
|
1870
|
+
}
|
|
1690
1871
|
const str = String(serverVal ?? "");
|
|
1691
1872
|
if (el.value !== str) el.value = str;
|
|
1692
1873
|
// A draft-backed field the server just emptied (e.g. after a successful submit) → drop the draft.
|
|
@@ -2055,20 +2236,29 @@ function _reapplyOptOps(comp: FlowComponent): void {
|
|
|
2055
2236
|
}
|
|
2056
2237
|
}
|
|
2057
2238
|
|
|
2058
|
-
// Time-travel: remember
|
|
2059
|
-
//
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2239
|
+
// Time-travel: remember what a component last dispatched, so the patch it produces is labelled
|
|
2240
|
+
// with that action name in the timeline — and so the panel can show the call that caused it.
|
|
2241
|
+
// Consumed (and cleared) when the patch lands.
|
|
2242
|
+
interface _PendingDispatch {
|
|
2243
|
+
method: string;
|
|
2244
|
+
args: unknown[];
|
|
2245
|
+
/** Filled in by `_sendCall`, which is where the batch of client writes is resolved. */
|
|
2246
|
+
updates?: Record<string, unknown>;
|
|
2247
|
+
}
|
|
2248
|
+
const _pendingActionByComp = new Map<string, _PendingDispatch>();
|
|
2249
|
+
|
|
2250
|
+
function _takeLastDispatch(compId: string): { action: string; sent?: _PendingDispatch } {
|
|
2251
|
+
const d = _pendingActionByComp.get(compId);
|
|
2063
2252
|
_pendingActionByComp.delete(compId);
|
|
2064
|
-
return
|
|
2253
|
+
if (!d) return { action: "action" };
|
|
2254
|
+
return { action: d.method, sent: d };
|
|
2065
2255
|
}
|
|
2066
2256
|
|
|
2067
2257
|
function _callAction(comp: FlowComponent, method: string, args: unknown[]): void {
|
|
2068
2258
|
_countActionDispatch(comp);
|
|
2069
2259
|
_setActionError(comp, false); // retrying clears any prior failed state
|
|
2070
2260
|
_setLoading(comp, true, method);
|
|
2071
|
-
if (isTimelineEnabled()) _pendingActionByComp.set(comp.id, method);
|
|
2261
|
+
if (isTimelineEnabled()) _pendingActionByComp.set(comp.id, { method, args });
|
|
2072
2262
|
_dispatchFrame(comp, method, args);
|
|
2073
2263
|
}
|
|
2074
2264
|
|
|
@@ -2710,12 +2900,32 @@ async function _navigateTo(href: string, options: NavigateOptions = {}): Promise
|
|
|
2710
2900
|
// the animation ends at the right offset instead of sliding there.
|
|
2711
2901
|
_applyScroll(scrollIntent, _hashOf(href));
|
|
2712
2902
|
};
|
|
2713
|
-
|
|
2714
|
-
|
|
2903
|
+
// The return value is typed here rather than cast at the call site, so reading
|
|
2904
|
+
// the transition's promises below costs no new assertion.
|
|
2905
|
+
const _startVT = (
|
|
2906
|
+
document as unknown as {
|
|
2907
|
+
startViewTransition?: (cb: () => void) => {
|
|
2908
|
+
ready?: Promise<unknown>;
|
|
2909
|
+
finished?: Promise<unknown>;
|
|
2910
|
+
};
|
|
2911
|
+
}
|
|
2912
|
+
).startViewTransition;
|
|
2715
2913
|
const _reduceMotion =
|
|
2716
2914
|
typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
2717
|
-
if (typeof _startVT === "function" && !_reduceMotion)
|
|
2718
|
-
|
|
2915
|
+
if (typeof _startVT === "function" && !_reduceMotion) {
|
|
2916
|
+
const _vt = _startVT.call(document, _swap);
|
|
2917
|
+
// A navigation that starts while a transition is still running makes the
|
|
2918
|
+
// browser skip the older one and reject its `ready` and `finished` promises
|
|
2919
|
+
// with an AbortError. Nothing has gone wrong — the swap itself already ran
|
|
2920
|
+
// inside the callback — but an unhandled rejection reaches `window.onerror`
|
|
2921
|
+
// and whatever error tracker the app has wired to it. Clicking through links
|
|
2922
|
+
// faster than the animation reported a stream of errors describing an
|
|
2923
|
+
// animation the browser skipped on purpose.
|
|
2924
|
+
void _vt?.ready?.catch(() => {});
|
|
2925
|
+
void _vt?.finished?.catch(() => {});
|
|
2926
|
+
} else {
|
|
2927
|
+
_swap(); // reduced-motion (or unsupported): swap instantly, no cross-page animation
|
|
2928
|
+
}
|
|
2719
2929
|
|
|
2720
2930
|
if (push) _pushHistoryEntry(href);
|
|
2721
2931
|
const incomingTitle = doc.querySelector("title");
|
|
@@ -2819,6 +3029,32 @@ function _setupPrefetch(): void {
|
|
|
2819
3029
|
},
|
|
2820
3030
|
{ passive: true },
|
|
2821
3031
|
);
|
|
3032
|
+
|
|
3033
|
+
// `flow:navigate.down` — prefetch on pointer-down, with no dwell.
|
|
3034
|
+
//
|
|
3035
|
+
// The middle setting between "never" and "every link the pointer crosses".
|
|
3036
|
+
// Hover prefetch is free speed on a handful of stable links and the opposite
|
|
3037
|
+
// on a dense list: moving the pointer down a table of a hundred rows asks the
|
|
3038
|
+
// server for a hundred pages, none of which anyone chose. Pointer-down fires
|
|
3039
|
+
// once, on the link the reader has committed to, and still beats the click by
|
|
3040
|
+
// however long the button stays held — around 100ms of ordinary human timing,
|
|
3041
|
+
// which is most of a fast page render.
|
|
3042
|
+
//
|
|
3043
|
+
// `pointerdown` rather than `mousedown` so a touch lands here too, and it is
|
|
3044
|
+
// passive because this never calls preventDefault — the click that follows is
|
|
3045
|
+
// handled by the navigation listener as usual, and finds the page waiting in
|
|
3046
|
+
// the cache.
|
|
3047
|
+
document.addEventListener(
|
|
3048
|
+
"pointerdown",
|
|
3049
|
+
(e) => {
|
|
3050
|
+
const a = (e.target as Element).closest?.(
|
|
3051
|
+
"a[flow\\:navigate\\.down]",
|
|
3052
|
+
) as HTMLAnchorElement | null;
|
|
3053
|
+
const href = a ? sameOrigin(a) : null;
|
|
3054
|
+
if (href) _prefetch(href);
|
|
3055
|
+
},
|
|
3056
|
+
{ passive: true },
|
|
3057
|
+
);
|
|
2822
3058
|
}
|
|
2823
3059
|
|
|
2824
3060
|
function _setupNavigationLinks(): void {
|
|
@@ -3252,7 +3488,7 @@ function _scanComponents(): void {
|
|
|
3252
3488
|
_setupPolling(comp);
|
|
3253
3489
|
_setupIntersect(comp);
|
|
3254
3490
|
_setupSort(comp);
|
|
3255
|
-
|
|
3491
|
+
_setupSocketListeners(comp);
|
|
3256
3492
|
_setupPresence(comp);
|
|
3257
3493
|
_setupShared(comp);
|
|
3258
3494
|
|
|
@@ -3318,7 +3554,7 @@ function _cleanupDisconnected(): void {
|
|
|
3318
3554
|
obs.disconnect();
|
|
3319
3555
|
_lazyObservers.delete(id);
|
|
3320
3556
|
}
|
|
3321
|
-
|
|
3557
|
+
_teardownSocketListeners(id);
|
|
3322
3558
|
_teardownPresence(id);
|
|
3323
3559
|
_teardownShared(id);
|
|
3324
3560
|
_optOps.delete(id);
|
|
@@ -3327,61 +3563,95 @@ function _cleanupDisconnected(): void {
|
|
|
3327
3563
|
}
|
|
3328
3564
|
}
|
|
3329
3565
|
|
|
3330
|
-
// ── Real-time @on('
|
|
3566
|
+
// ── Real-time @on('socket:…') listeners ───────────────────────────────────────
|
|
3331
3567
|
//
|
|
3332
|
-
// A listener whose event name is `
|
|
3333
|
-
//
|
|
3334
|
-
// (the standard broadcast client the app configures). When the broadcast
|
|
3568
|
+
// A listener whose event name is `socket:CHANNEL,EVENT` (or socket-private: /
|
|
3569
|
+
// socket-presence:) subscribes to a broadcast channel. When the broadcast
|
|
3335
3570
|
// arrives, the component's @on method runs server-side via a normal action frame —
|
|
3336
|
-
// so a broadcast updates the component exactly like any other event.
|
|
3337
|
-
//
|
|
3571
|
+
// so a broadcast updates the component exactly like any other event.
|
|
3572
|
+
//
|
|
3573
|
+
// The client is bundled into this runtime and created on first use, so a page
|
|
3574
|
+
// that declares a listener is live without the app shipping any script. That is
|
|
3575
|
+
// not a convenience: these subscriptions used to be *silently inert* when the
|
|
3576
|
+
// global was missing — no error, no warning — so a live feature with no script
|
|
3577
|
+
// looked exactly like a live feature that was never written. An app that needs a
|
|
3578
|
+
// configured client (a different host, its own auth endpoint) still sets
|
|
3579
|
+
// `window.Socket` itself before the runtime loads, and that one wins.
|
|
3580
|
+
|
|
3581
|
+
/**
|
|
3582
|
+
* The broadcast client: whatever the app published, or one made on demand.
|
|
3583
|
+
*
|
|
3584
|
+
* Constructed on first use rather than at load, so an app with no realtime
|
|
3585
|
+
* feature never opens a second connection. `new Socket()` needs no arguments in
|
|
3586
|
+
* a browser — it derives its URL from `location` and posts to
|
|
3587
|
+
* `/broadcasting/auth` — which is exactly the wiring every app was writing by
|
|
3588
|
+
* hand.
|
|
3589
|
+
*/
|
|
3590
|
+
function _socketClient(): SocketClient {
|
|
3591
|
+
const w = window as unknown as { Socket?: SocketClient };
|
|
3592
|
+
return (w.Socket ??= new BundledSocket() as unknown as SocketClient);
|
|
3593
|
+
}
|
|
3594
|
+
|
|
3595
|
+
/**
|
|
3596
|
+
* The client, but only if one already exists.
|
|
3597
|
+
*
|
|
3598
|
+
* Teardown and whisper paths use this: creating a connection in order to leave a
|
|
3599
|
+
* channel, or to whisper on a page that never joined one, would open a socket to
|
|
3600
|
+
* do nothing with it.
|
|
3601
|
+
*/
|
|
3602
|
+
function _existingSocketClient(): SocketClient | undefined {
|
|
3603
|
+
return (window as unknown as { Socket?: SocketClient }).Socket;
|
|
3604
|
+
}
|
|
3338
3605
|
|
|
3339
|
-
type
|
|
3340
|
-
const
|
|
3606
|
+
type SocketSub = { channel: string; kind: "" | "-private" | "-presence" };
|
|
3607
|
+
const _socketSubs = new Map<string, SocketSub[]>();
|
|
3341
3608
|
|
|
3342
|
-
/** Parse `
|
|
3343
|
-
export function
|
|
3609
|
+
/** Parse `socket[-private|-presence]:channel,event` → parts, or null if not a socket listener. @internal */
|
|
3610
|
+
export function _parseSocketListener(
|
|
3344
3611
|
name: string,
|
|
3345
|
-
): { kind:
|
|
3346
|
-
const m = /^
|
|
3612
|
+
): { kind: SocketSub["kind"]; channel: string; event: string } | null {
|
|
3613
|
+
const m = /^socket(-private|-presence)?:/.exec(name);
|
|
3347
3614
|
if (!m) return null;
|
|
3348
|
-
const kind = (m[1] ?? "") as
|
|
3615
|
+
const kind = (m[1] ?? "") as SocketSub["kind"];
|
|
3349
3616
|
const rest = name.slice(m[0].length);
|
|
3350
3617
|
const ci = rest.lastIndexOf(","); // split on the LAST comma (channels may contain dots, not commas)
|
|
3351
3618
|
if (ci === -1) return null;
|
|
3352
3619
|
return { kind, channel: rest.slice(0, ci), event: rest.slice(ci + 1) };
|
|
3353
3620
|
}
|
|
3354
3621
|
|
|
3355
|
-
interface
|
|
3356
|
-
listen(event: string, cb: (payload: unknown) => void):
|
|
3357
|
-
here?(cb: (payload: unknown) => void):
|
|
3358
|
-
joining?(cb: (payload: unknown) => void):
|
|
3359
|
-
leaving?(cb: (payload: unknown) => void):
|
|
3622
|
+
interface SocketChannel {
|
|
3623
|
+
listen(event: string, cb: (payload: unknown) => void): SocketChannel;
|
|
3624
|
+
here?(cb: (payload: unknown) => void): SocketChannel;
|
|
3625
|
+
joining?(cb: (payload: unknown) => void): SocketChannel;
|
|
3626
|
+
leaving?(cb: (payload: unknown) => void): SocketChannel;
|
|
3360
3627
|
}
|
|
3361
|
-
interface
|
|
3362
|
-
channel(name: string):
|
|
3363
|
-
private(name: string):
|
|
3364
|
-
join(name: string):
|
|
3628
|
+
interface SocketClient {
|
|
3629
|
+
channel(name: string): SocketChannel;
|
|
3630
|
+
private(name: string): SocketChannel;
|
|
3631
|
+
join(name: string): SocketChannel;
|
|
3365
3632
|
leave(name: string): void;
|
|
3366
3633
|
}
|
|
3367
3634
|
|
|
3368
|
-
function
|
|
3369
|
-
|
|
3635
|
+
function _setupSocketListeners(comp: FlowComponent): void {
|
|
3636
|
+
// The snapshot decides whether this page needs a connection at all — asking
|
|
3637
|
+
// for the client first would open one on every page, including the ones with
|
|
3638
|
+
// nothing to subscribe to.
|
|
3370
3639
|
const listeners = comp.snapshot.memo.listeners;
|
|
3371
|
-
if (!
|
|
3640
|
+
if (!listeners) return;
|
|
3641
|
+
const client = _socketClient();
|
|
3372
3642
|
|
|
3373
|
-
const subs:
|
|
3643
|
+
const subs: SocketSub[] = [];
|
|
3374
3644
|
for (const [name, method] of Object.entries(listeners)) {
|
|
3375
|
-
const parsed =
|
|
3645
|
+
const parsed = _parseSocketListener(name);
|
|
3376
3646
|
if (!parsed) continue;
|
|
3377
3647
|
const { kind, channel, event } = parsed;
|
|
3378
3648
|
|
|
3379
3649
|
const ch =
|
|
3380
3650
|
kind === "-private"
|
|
3381
|
-
?
|
|
3651
|
+
? client.private(channel)
|
|
3382
3652
|
: kind === "-presence"
|
|
3383
|
-
?
|
|
3384
|
-
:
|
|
3653
|
+
? client.join(channel)
|
|
3654
|
+
: client.channel(channel);
|
|
3385
3655
|
|
|
3386
3656
|
const cb = (payload: unknown) => _callAction(comp, method, [payload]);
|
|
3387
3657
|
// Presence sub-events (here/joining/leaving) use their own methods; everything
|
|
@@ -3393,43 +3663,43 @@ function _setupEchoListeners(comp: FlowComponent): void {
|
|
|
3393
3663
|
}
|
|
3394
3664
|
subs.push({ channel, kind });
|
|
3395
3665
|
}
|
|
3396
|
-
if (subs.length)
|
|
3666
|
+
if (subs.length) _socketSubs.set(comp.id, subs);
|
|
3397
3667
|
}
|
|
3398
3668
|
|
|
3399
|
-
function
|
|
3400
|
-
const
|
|
3401
|
-
const subs =
|
|
3402
|
-
if (
|
|
3669
|
+
function _teardownSocketListeners(id: string): void {
|
|
3670
|
+
const client = _existingSocketClient();
|
|
3671
|
+
const subs = _socketSubs.get(id);
|
|
3672
|
+
if (client && subs)
|
|
3403
3673
|
for (const s of subs) {
|
|
3404
3674
|
try {
|
|
3405
|
-
|
|
3675
|
+
client.leave(s.channel);
|
|
3406
3676
|
} catch {
|
|
3407
3677
|
/* ignore */
|
|
3408
3678
|
}
|
|
3409
3679
|
}
|
|
3410
|
-
|
|
3680
|
+
_socketSubs.delete(id);
|
|
3411
3681
|
}
|
|
3412
3682
|
|
|
3413
3683
|
// ── @presence channels ────────────────────────────────────────────────────────
|
|
3414
3684
|
// A component with @presence props carries its resolved channel(s) in memo.presence.
|
|
3415
3685
|
// Join each presence channel and, on here/joining/leaving, dispatch `$presence` so the
|
|
3416
3686
|
// server refills the member list and re-renders. Cursors/ephemeral state ride the same
|
|
3417
|
-
// channel via
|
|
3687
|
+
// channel via client whispers (see _presenceChannelFor / whisper helpers below).
|
|
3418
3688
|
|
|
3419
|
-
interface
|
|
3420
|
-
whisper?(event: string, data: unknown):
|
|
3421
|
-
listenForWhisper?(event: string, cb: (data: unknown) => void):
|
|
3689
|
+
interface PresenceSocketChannel extends SocketChannel {
|
|
3690
|
+
whisper?(event: string, data: unknown): PresenceSocketChannel;
|
|
3691
|
+
listenForWhisper?(event: string, cb: (data: unknown) => void): PresenceSocketChannel;
|
|
3422
3692
|
}
|
|
3423
3693
|
const _presenceChannels = new Map<string, string[]>(); // componentId → joined channels
|
|
3424
3694
|
|
|
3425
3695
|
function _setupPresence(comp: FlowComponent): void {
|
|
3426
|
-
const Echo = (window as unknown as { Echo?: EchoClient }).Echo;
|
|
3427
3696
|
const presence = comp.snapshot.memo.presence;
|
|
3428
|
-
if (!
|
|
3697
|
+
if (!presence || presence.length === 0) return;
|
|
3698
|
+
const client = _socketClient();
|
|
3429
3699
|
|
|
3430
3700
|
const channels: string[] = [];
|
|
3431
3701
|
for (const { channel } of presence) {
|
|
3432
|
-
const ch =
|
|
3702
|
+
const ch = client.join(channel);
|
|
3433
3703
|
const refresh = (): void => _callAction(comp, "$presence", []);
|
|
3434
3704
|
ch.here?.(refresh);
|
|
3435
3705
|
ch.joining?.(refresh);
|
|
@@ -3440,12 +3710,12 @@ function _setupPresence(comp: FlowComponent): void {
|
|
|
3440
3710
|
}
|
|
3441
3711
|
|
|
3442
3712
|
function _teardownPresence(id: string): void {
|
|
3443
|
-
const
|
|
3713
|
+
const client = _existingSocketClient();
|
|
3444
3714
|
const channels = _presenceChannels.get(id);
|
|
3445
|
-
if (
|
|
3715
|
+
if (client && channels)
|
|
3446
3716
|
for (const c of channels) {
|
|
3447
3717
|
try {
|
|
3448
|
-
|
|
3718
|
+
client.leave(c);
|
|
3449
3719
|
} catch {
|
|
3450
3720
|
/* ignore */
|
|
3451
3721
|
}
|
|
@@ -3461,21 +3731,21 @@ function _presenceChannelFor(comp: FlowComponent): string | null {
|
|
|
3461
3731
|
/**
|
|
3462
3732
|
* Broadcast ephemeral state (e.g. a cursor position) to the other members of the
|
|
3463
3733
|
* component's presence channel — client-only, never hits the server or the DB. Pair with
|
|
3464
|
-
* `onWhisper`. No-op without `window.
|
|
3734
|
+
* `onWhisper`. No-op without `window.client` or a presence channel.
|
|
3465
3735
|
*/
|
|
3466
3736
|
function _whisper(comp: FlowComponent, event: string, data: unknown): void {
|
|
3467
|
-
const
|
|
3737
|
+
const client = _existingSocketClient();
|
|
3468
3738
|
const channel = _presenceChannelFor(comp);
|
|
3469
|
-
if (!
|
|
3470
|
-
(
|
|
3739
|
+
if (!client || !channel) return;
|
|
3740
|
+
(client.join(channel) as PresenceSocketChannel).whisper?.(event, data);
|
|
3471
3741
|
}
|
|
3472
3742
|
|
|
3473
3743
|
/** Listen for peers' whispers on the component's presence channel (e.g. cursor moves). */
|
|
3474
3744
|
function _onWhisper(comp: FlowComponent, event: string, cb: (data: unknown) => void): void {
|
|
3475
|
-
const
|
|
3745
|
+
const client = _existingSocketClient();
|
|
3476
3746
|
const channel = _presenceChannelFor(comp);
|
|
3477
|
-
if (!
|
|
3478
|
-
(
|
|
3747
|
+
if (!client || !channel) return;
|
|
3748
|
+
(client.join(channel) as PresenceSocketChannel).listenForWhisper?.(event, cb);
|
|
3479
3749
|
}
|
|
3480
3750
|
|
|
3481
3751
|
// ── @task cancellation ─────────────────────────────────────────────────────────
|
|
@@ -3503,32 +3773,32 @@ function _cancelTask(comp: FlowComponent): void {
|
|
|
3503
3773
|
// A component with @shared props carries its resolved channel(s) in memo.shared. Join each
|
|
3504
3774
|
// channel and, on the `flow:shared` broadcast, dispatch `$shared` so the server re-reads
|
|
3505
3775
|
// the room store and re-renders — every subscriber converges to the same state. Shared and
|
|
3506
|
-
// presence channels are joined the same way (
|
|
3776
|
+
// presence channels are joined the same way (client.join), so one room channel can carry both.
|
|
3507
3777
|
|
|
3508
3778
|
const SHARED_EVENT = "flow:shared";
|
|
3509
3779
|
const _sharedChannels = new Map<string, string[]>(); // componentId → joined channels
|
|
3510
3780
|
|
|
3511
3781
|
function _setupShared(comp: FlowComponent): void {
|
|
3512
|
-
const Echo = (window as unknown as { Echo?: EchoClient }).Echo;
|
|
3513
3782
|
const shared = comp.snapshot.memo.shared;
|
|
3514
|
-
if (!
|
|
3783
|
+
if (!shared || shared.length === 0) return;
|
|
3784
|
+
const client = _socketClient();
|
|
3515
3785
|
|
|
3516
3786
|
const channels: string[] = [];
|
|
3517
3787
|
for (const { channel } of shared) {
|
|
3518
3788
|
const converge = (): void => _callAction(comp, "$shared", []);
|
|
3519
|
-
|
|
3789
|
+
client.join(channel).listen(SHARED_EVENT, converge);
|
|
3520
3790
|
channels.push(channel);
|
|
3521
3791
|
}
|
|
3522
3792
|
if (channels.length) _sharedChannels.set(comp.id, channels);
|
|
3523
3793
|
}
|
|
3524
3794
|
|
|
3525
3795
|
function _teardownShared(id: string): void {
|
|
3526
|
-
const
|
|
3796
|
+
const client = _existingSocketClient();
|
|
3527
3797
|
const channels = _sharedChannels.get(id);
|
|
3528
|
-
if (
|
|
3798
|
+
if (client && channels)
|
|
3529
3799
|
for (const c of channels) {
|
|
3530
3800
|
try {
|
|
3531
|
-
|
|
3801
|
+
client.leave(c);
|
|
3532
3802
|
} catch {
|
|
3533
3803
|
/* ignore */
|
|
3534
3804
|
}
|