@zerotal/flow 1.4.0 → 1.5.1
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 +278 -4
- package/README.md +1 -1
- package/package.json +8 -9
- package/src/Component.ts +214 -41
- package/src/browser/FlowBrowser.ts +270 -0
- package/src/browser/cdp.ts +273 -0
- package/src/browser/index.ts +19 -0
- package/src/client/FlowComponent.ts +1 -1
- package/src/client/bridge.ts +160 -24
- package/src/client/headless.ts +2 -1
- package/src/components.ts +276 -3
- package/src/config.ts +2 -2
- package/src/decorators.ts +122 -35
- package/src/dehydrate.ts +2 -2
- package/src/events.ts +1 -1
- package/src/index.ts +8 -2
- package/src/jsx-runtime.ts +77 -5
- package/src/pagination.ts +5 -7
- package/src/provider/FlowProvider.ts +46 -13
- package/src/router.ts +186 -116
- package/src/sections.ts +121 -0
- package/src/store.ts +0 -1
- package/src/streaming.ts +92 -0
- package/src/synths/index.ts +2 -2
- package/src/testing.ts +1 -1
- package/src/types.ts +1 -1
- package/src/uploads/FileUploads.ts +3 -4
- package/src/uploads/TemporaryUploadedFile.ts +1 -1
package/src/client/bridge.ts
CHANGED
|
@@ -277,7 +277,7 @@ const _loadingTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
|
277
277
|
const LOADING_DELAY_MS = 200;
|
|
278
278
|
|
|
279
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
|
|
280
|
+
// a WebSocket $set on every character. Discrete
|
|
281
281
|
// controls — checkbox, radio, select, range, date, colour — send immediately (a deliberate pick,
|
|
282
282
|
// not a stream of keystrokes). The default binding (no `live`) is deferred and never affected.
|
|
283
283
|
const MODEL_DEBOUNCE_MS = 150;
|
|
@@ -452,9 +452,16 @@ function _updateDirty(comp: FlowComponent): void {
|
|
|
452
452
|
let _isOnline = true;
|
|
453
453
|
|
|
454
454
|
function _setConnectionState(online: boolean): void {
|
|
455
|
+
// The attribute mirrors the *current* state, so it is stamped even when that
|
|
456
|
+
// state hasn't changed. The bridge starts optimistic (`_isOnline = true`), so
|
|
457
|
+
// a page whose socket connects on the first attempt — the normal case — would
|
|
458
|
+
// otherwise never carry the attribute at all, and a stylesheet or a readiness
|
|
459
|
+
// check keyed on `[data-flow-connection="online"]` would wait forever for a
|
|
460
|
+
// page that is in fact perfectly connected.
|
|
461
|
+
document.body.setAttribute("data-flow-connection", online ? "online" : "offline");
|
|
462
|
+
|
|
455
463
|
if (_isOnline === online) return;
|
|
456
464
|
_isOnline = online;
|
|
457
|
-
document.body.setAttribute("data-flow-connection", online ? "online" : "offline");
|
|
458
465
|
document.dispatchEvent(new CustomEvent(online ? "flow:online" : "flow:offline"));
|
|
459
466
|
|
|
460
467
|
const offline = !online;
|
|
@@ -495,10 +502,37 @@ let _httpMode = false;
|
|
|
495
502
|
let _wsFailures = 0;
|
|
496
503
|
const HTTP_FALLBACK_AFTER = 3; // consecutive failed WS handshakes before switching to HTTP
|
|
497
504
|
|
|
505
|
+
/**
|
|
506
|
+
* The origin guard refused this page, and no retry will change that.
|
|
507
|
+
*
|
|
508
|
+
* Reported once, in full, because the alternative is the worst failure this app has: the
|
|
509
|
+
* HTML renders, the console shows a generic "action failed", and the only symptom is that
|
|
510
|
+
* nothing a user clicks does anything. The connection state drops to offline too — the app
|
|
511
|
+
* genuinely cannot act, so actions queue behind `flow:offline` directives instead of being
|
|
512
|
+
* fired at an endpoint that will refuse every one of them.
|
|
513
|
+
*/
|
|
514
|
+
let _originRefused = false;
|
|
515
|
+
function _reportOriginRefused(body: string): void {
|
|
516
|
+
if (_originRefused) return;
|
|
517
|
+
_originRefused = true;
|
|
518
|
+
console.error(
|
|
519
|
+
`[Flow] Actions refused: the server rejected this page's origin (${location.origin}).\n` +
|
|
520
|
+
`Behind a reverse proxy the app's own origin is the loopback address it bound to, so ` +
|
|
521
|
+
`the public origin has to be configured for it to accept browser-initiated actions.\n` +
|
|
522
|
+
`Fix: set \`url\` in config/app.ts to ${location.origin} — AppConfig fills ` +
|
|
523
|
+
`app.allowedOrigins from it.` +
|
|
524
|
+
(body ? `\nServer said: ${body}` : ""),
|
|
525
|
+
);
|
|
526
|
+
_setConnectionState(false);
|
|
527
|
+
}
|
|
528
|
+
|
|
498
529
|
function _enableHttpMode(): void {
|
|
499
530
|
if (_httpMode) return;
|
|
500
531
|
_httpMode = true;
|
|
501
|
-
console.warn(
|
|
532
|
+
console.warn(
|
|
533
|
+
"[Flow] WebSocket unavailable — falling back to HTTP requests. If actions also fail, " +
|
|
534
|
+
"check that the proxy forwards /__flow/* over HTTP/1.1 and does not gate it behind auth.",
|
|
535
|
+
);
|
|
502
536
|
_setConnectionState(true); // HTTP works, so we're "online" for offline directives
|
|
503
537
|
// The socket that dropped may never come back, so this is the reconnect for anything it
|
|
504
538
|
// left mid-action — resync over HTTP before replaying, same order as the WS path.
|
|
@@ -517,12 +551,25 @@ function _httpSend(frame: unknown, compId: string): void {
|
|
|
517
551
|
credentials: "include",
|
|
518
552
|
body: JSON.stringify(frame),
|
|
519
553
|
})
|
|
520
|
-
.then((r) =>
|
|
554
|
+
.then(async (r) => {
|
|
555
|
+
if (r.ok) return r.json();
|
|
556
|
+
// The status is the diagnosis, and the body carries the server's own words for it.
|
|
557
|
+
// Both are dropped by a bare `HTTP 403`, which is what made this failure so quiet.
|
|
558
|
+
const body = await r.text().catch(() => "");
|
|
559
|
+
const err = new Error(`HTTP ${r.status}${body ? `: ${body}` : ""}`) as Error & {
|
|
560
|
+
status?: number;
|
|
561
|
+
body?: string;
|
|
562
|
+
};
|
|
563
|
+
err.status = r.status;
|
|
564
|
+
err.body = body;
|
|
565
|
+
throw err;
|
|
566
|
+
})
|
|
521
567
|
.then((frames: unknown) => {
|
|
522
568
|
if (Array.isArray(frames)) for (const f of frames) void _handleServerFrame(f as ServerFrame);
|
|
523
569
|
})
|
|
524
|
-
.catch((e) => {
|
|
525
|
-
|
|
570
|
+
.catch((e: Error & { status?: number; body?: string }) => {
|
|
571
|
+
if (e.status === 403) _reportOriginRefused(e.body ?? "");
|
|
572
|
+
else console.error("[Flow] HTTP action failed:", e);
|
|
526
573
|
_resolveAck(compId); // never wedge the per-component queue
|
|
527
574
|
});
|
|
528
575
|
}
|
|
@@ -1206,16 +1253,37 @@ function _renderTimelineInto(el: HTMLElement): void {
|
|
|
1206
1253
|
};
|
|
1207
1254
|
}
|
|
1208
1255
|
|
|
1209
|
-
|
|
1210
|
-
|
|
1256
|
+
/** The four corners the timeline pill can dock to. */
|
|
1257
|
+
const _TL_CORNERS = ["bottom-left", "bottom-right", "top-left", "top-right"] as const;
|
|
1258
|
+
type _TlCorner = (typeof _TL_CORNERS)[number];
|
|
1259
|
+
|
|
1260
|
+
/**
|
|
1261
|
+
* Which corner the fallback pill docks to. `data-flow-tl-corner` on <html> or <body>
|
|
1262
|
+
* overrides; the default is bottom-left, where it stays clear of an app's own left-rail
|
|
1263
|
+
* navigation (a top-docked panel at max z-index sat exactly on a sidebar's first item).
|
|
1264
|
+
*/
|
|
1265
|
+
function _timelineCorner(): _TlCorner {
|
|
1266
|
+
const raw =
|
|
1267
|
+
document.documentElement.dataset["flowTlCorner"] ?? document.body.dataset["flowTlCorner"];
|
|
1268
|
+
return (_TL_CORNERS as readonly string[]).includes(raw ?? "")
|
|
1269
|
+
? (raw as _TlCorner)
|
|
1270
|
+
: "bottom-left";
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
// A compact, dev-only scrubbing panel docked bottom-left by default (see _timelineCorner).
|
|
1274
|
+
// The console API above is the primary interface; this is a convenience over it.
|
|
1211
1275
|
function _setupTimelinePanel(): void {
|
|
1212
1276
|
if (document.getElementById("flow-tl-panel")) return;
|
|
1213
1277
|
const style = document.createElement("style");
|
|
1214
1278
|
style.textContent = `
|
|
1215
|
-
#flow-tl-panel{position:fixed;
|
|
1279
|
+
#flow-tl-panel{position:fixed;z-index:2147483647;display:flex;flex-direction:column;gap:6px;font:12px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace;color:#e5e7eb}
|
|
1280
|
+
#flow-tl-panel[data-corner="bottom-left"]{left:10px;bottom:10px;flex-direction:column-reverse}
|
|
1281
|
+
#flow-tl-panel[data-corner="bottom-right"]{right:10px;bottom:10px;flex-direction:column-reverse;align-items:flex-end}
|
|
1282
|
+
#flow-tl-panel[data-corner="top-left"]{left:10px;top:10px}
|
|
1283
|
+
#flow-tl-panel[data-corner="top-right"]{right:10px;top:10px;align-items:flex-end}
|
|
1216
1284
|
#flow-tl-panel *{box-sizing:border-box}
|
|
1217
|
-
.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)}
|
|
1218
|
-
.flow-tl-body{
|
|
1285
|
+
.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);align-self:inherit}
|
|
1286
|
+
.flow-tl-body{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)}
|
|
1219
1287
|
.flow-tl-head{display:flex;align-items:center;gap:8px;padding:8px 10px;border-bottom:1px solid #1f2937;font-weight:600}
|
|
1220
1288
|
.flow-tl-head .sp{flex:1}
|
|
1221
1289
|
.flow-tl-live{background:#059669;color:#fff;border:0;border-radius:6px;padding:3px 8px;cursor:pointer}
|
|
@@ -1233,6 +1301,7 @@ function _setupTimelinePanel(): void {
|
|
|
1233
1301
|
|
|
1234
1302
|
const root = document.createElement("div");
|
|
1235
1303
|
root.id = "flow-tl-panel";
|
|
1304
|
+
root.dataset["corner"] = _timelineCorner();
|
|
1236
1305
|
root.innerHTML =
|
|
1237
1306
|
`<button class="flow-tl-toggle" type="button">⏱ <b class="flow-tl-count">0</b></button>` +
|
|
1238
1307
|
`<div class="flow-tl-body" hidden>` +
|
|
@@ -1846,16 +1915,6 @@ function _focusFirstError(comp: FlowComponent): void {
|
|
|
1846
1915
|
if (invalid) (invalid as HTMLElement).focus();
|
|
1847
1916
|
}
|
|
1848
1917
|
|
|
1849
|
-
// Local copy by design: this file ships to browsers as part of the client
|
|
1850
|
-
// bundle and cannot import server-side @zerotal/core helpers.
|
|
1851
|
-
function debounce<T extends unknown[]>(fn: (...args: T) => void, ms: number): (...args: T) => void {
|
|
1852
|
-
let timer: ReturnType<typeof setTimeout>;
|
|
1853
|
-
return (...args: T) => {
|
|
1854
|
-
clearTimeout(timer);
|
|
1855
|
-
timer = setTimeout(() => fn(...args), ms);
|
|
1856
|
-
};
|
|
1857
|
-
}
|
|
1858
|
-
|
|
1859
1918
|
/**
|
|
1860
1919
|
* Find the nearest ancestor carrying `flow:<event>`, with or without modifiers.
|
|
1861
1920
|
*
|
|
@@ -2005,12 +2064,75 @@ function _takeLastAction(compId: string): string {
|
|
|
2005
2064
|
}
|
|
2006
2065
|
|
|
2007
2066
|
function _callAction(comp: FlowComponent, method: string, args: unknown[]): void {
|
|
2067
|
+
_countActionDispatch(comp);
|
|
2008
2068
|
_setActionError(comp, false); // retrying clears any prior failed state
|
|
2009
2069
|
_setLoading(comp, true, method);
|
|
2010
2070
|
if (isTimelineEnabled()) _pendingActionByComp.set(comp.id, method);
|
|
2011
2071
|
_dispatchFrame(comp, method, args);
|
|
2012
2072
|
}
|
|
2013
2073
|
|
|
2074
|
+
// ── Client-expression writes sync to the server ───────────────────────────────
|
|
2075
|
+
// `onClick={() => (this.selected = row.id)}` writes the reactive store, but render()
|
|
2076
|
+
// runs on the server — without a round-trip the page never reflects the write, and
|
|
2077
|
+
// nothing errors. (It *looked* reactive whenever a later action happened to flush it,
|
|
2078
|
+
// which is the worst kind of sometimes.) So: while a client expression evaluates,
|
|
2079
|
+
// every write to an exposed prop is recorded; if the expression wrote state and did
|
|
2080
|
+
// not itself dispatch an action on that component (an action's frame already carries
|
|
2081
|
+
// all pending writes), one `$rerender` is sent — state applied through the same
|
|
2082
|
+
// allowlist + updating()/updated() hooks, re-rendered, patched. No loading state:
|
|
2083
|
+
// it is a sync, not a user action. Client-only UI state belongs in `this.store()`,
|
|
2084
|
+
// which never round-trips.
|
|
2085
|
+
|
|
2086
|
+
/** Writes recorded during the currently-evaluating client expression (null: none active). */
|
|
2087
|
+
let _exprWrites: Set<string> | null = null;
|
|
2088
|
+
/** Actions dispatched per component, to detect "the expression already sent one". */
|
|
2089
|
+
const _actionSeq = new Map<string, number>();
|
|
2090
|
+
|
|
2091
|
+
/** @internal Count an action dispatch on `comp` (called by _callAction; exported for tests). */
|
|
2092
|
+
export function _countActionDispatch(comp: { id: string }): void {
|
|
2093
|
+
_actionSeq.set(comp.id, (_actionSeq.get(comp.id) ?? 0) + 1);
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2096
|
+
/** @internal Record a write to an exposed prop; no-op outside an expression evaluation. */
|
|
2097
|
+
export function _noteExprWrite(key: string): void {
|
|
2098
|
+
_exprWrites?.add(key);
|
|
2099
|
+
}
|
|
2100
|
+
|
|
2101
|
+
/** @internal Opaque state carried from {@link _beginExprEval} to {@link _endExprEval}. */
|
|
2102
|
+
export interface ExprEvalToken {
|
|
2103
|
+
writes: Set<string>;
|
|
2104
|
+
prev: Set<string> | null;
|
|
2105
|
+
seqBefore: number;
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
/** @internal Start recording exposed-prop writes for one expression evaluation. */
|
|
2109
|
+
export function _beginExprEval(comp: { id: string }): ExprEvalToken {
|
|
2110
|
+
const token: ExprEvalToken = {
|
|
2111
|
+
writes: new Set(),
|
|
2112
|
+
prev: _exprWrites,
|
|
2113
|
+
seqBefore: _actionSeq.get(comp.id) ?? 0,
|
|
2114
|
+
};
|
|
2115
|
+
_exprWrites = token.writes;
|
|
2116
|
+
return token;
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
/**
|
|
2120
|
+
* @internal Stop recording and decide whether the component needs a sync round-trip:
|
|
2121
|
+
* it does when the expression wrote at least one exposed prop, dispatched no action on
|
|
2122
|
+
* this component itself, and the writes left actual pending changes (a toggle-back to
|
|
2123
|
+
* the canonical value needs nothing).
|
|
2124
|
+
*/
|
|
2125
|
+
export function _endExprEval(
|
|
2126
|
+
comp: { id: string },
|
|
2127
|
+
token: ExprEvalToken,
|
|
2128
|
+
pendingCount: number,
|
|
2129
|
+
): boolean {
|
|
2130
|
+
_exprWrites = token.prev;
|
|
2131
|
+
if (token.writes.size === 0) return false;
|
|
2132
|
+
if ((_actionSeq.get(comp.id) ?? 0) !== token.seqBefore) return false;
|
|
2133
|
+
return pendingCount > 0;
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2014
2136
|
/**
|
|
2015
2137
|
* Execute a flow:click / flow:submit value that contains $flow references.
|
|
2016
2138
|
* Emitted from inline arrow handlers in JSX — `onClick={(e) => this.x = 0}` — where
|
|
@@ -2020,15 +2142,24 @@ function _callAction(comp: FlowComponent, method: string, args: unknown[]): void
|
|
|
2020
2142
|
* Mirrors Alpine's own `x-on` semantics: if the expression evaluates to a function
|
|
2021
2143
|
* (the author wrote an arrow), call it with the DOM event. A bare statement
|
|
2022
2144
|
* (`$flow.x = 0`) evaluates directly and returns a non-function, so nothing is called.
|
|
2145
|
+
*
|
|
2146
|
+
* A write to an exposed prop syncs to the server afterwards (see _beginExprEval) —
|
|
2147
|
+
* a partial write before a throw still syncs, since the client state did change.
|
|
2023
2148
|
*/
|
|
2024
2149
|
function _evalClientExpr(comp: FlowComponent, el: Element, expr: string, event?: Event): void {
|
|
2025
2150
|
const A = Alpine as unknown as { evaluate(el: Element, expr: string): unknown };
|
|
2151
|
+
const token = _beginExprEval(comp);
|
|
2152
|
+
let needsSync = false;
|
|
2026
2153
|
try {
|
|
2027
2154
|
const result = A.evaluate(el, expr);
|
|
2028
2155
|
if (typeof result === "function") (result as (e?: Event) => unknown)(event);
|
|
2029
2156
|
} catch (e) {
|
|
2030
2157
|
console.error("[Flow] client expression error:", e, "\nExpr:", expr);
|
|
2158
|
+
} finally {
|
|
2159
|
+
needsSync = _endExprEval(comp, token, Object.keys(_pendingUpdates(comp)).length);
|
|
2031
2160
|
}
|
|
2161
|
+
// Serialised like any action; carries every pending write as the frame's `updates`.
|
|
2162
|
+
if (needsSync) _dispatchFrame(comp, "$rerender", []);
|
|
2032
2163
|
}
|
|
2033
2164
|
|
|
2034
2165
|
/**
|
|
@@ -2293,7 +2424,7 @@ function _linkActive(href: string, path: string, exact = false): boolean {
|
|
|
2293
2424
|
function _updateCurrentLinks(): void {
|
|
2294
2425
|
const path = window.location.pathname;
|
|
2295
2426
|
|
|
2296
|
-
// Auto data-current on every navigate link matching the URL
|
|
2427
|
+
// Auto data-current on every navigate link matching the URL.
|
|
2297
2428
|
// Style with `class="data-current:font-bold"` or `[data-current] {}`. Opt out
|
|
2298
2429
|
// per-link with `flow:current.ignore` (the <Link current={false}> prop), or force it
|
|
2299
2430
|
// always-on with `flow:current.force` (the <Link current={true}> prop) so the link stays
|
|
@@ -3404,11 +3535,13 @@ function _isWritable(comp: FlowComponent, key: string): boolean {
|
|
|
3404
3535
|
* Register $flow and $errors Alpine magic helpers.
|
|
3405
3536
|
* Called from index.ts with the Alpine instance before Alpine.start().
|
|
3406
3537
|
*
|
|
3407
|
-
* $flow
|
|
3538
|
+
* The $flow client API:
|
|
3408
3539
|
*
|
|
3409
3540
|
* $flow.method(args) — call an @expose method
|
|
3410
3541
|
* $flow.prop — read a reactive property
|
|
3411
|
-
* $flow.prop = value — write
|
|
3542
|
+
* $flow.prop = value — write; inside a client expression that dispatches no
|
|
3543
|
+
* action, the write syncs to the server afterwards so
|
|
3544
|
+
* render() reflects it (client-only state → $store)
|
|
3412
3545
|
*
|
|
3413
3546
|
* $flow.$refresh() — re-render the component
|
|
3414
3547
|
* $flow.$commit() — alias for $refresh
|
|
@@ -3618,6 +3751,9 @@ export function registerFlowMagic(alpine: AlpineType): void {
|
|
|
3618
3751
|
_syncDeclarative(comp);
|
|
3619
3752
|
// flow:model inputs aren't Alpine-bound, so push the new value into them explicitly.
|
|
3620
3753
|
_syncModelInputs(comp);
|
|
3754
|
+
// Inside a client expression, the write syncs to the server when the
|
|
3755
|
+
// expression dispatches no action of its own (see _beginExprEval).
|
|
3756
|
+
_noteExprWrite(key);
|
|
3621
3757
|
return true;
|
|
3622
3758
|
},
|
|
3623
3759
|
});
|
package/src/client/headless.ts
CHANGED
|
@@ -901,7 +901,8 @@ export function flowCommand(config: { items: FlowCommandItem[]; hotkey?: string
|
|
|
901
901
|
this.open = false;
|
|
902
902
|
},
|
|
903
903
|
toggle(this: AlpineCtx & CommandState) {
|
|
904
|
-
this.open
|
|
904
|
+
if (this.open) this.hide();
|
|
905
|
+
else this.show();
|
|
905
906
|
},
|
|
906
907
|
move(this: AlpineCtx & CommandState, by: number) {
|
|
907
908
|
const total = this.results().length;
|
package/src/components.ts
CHANGED
|
@@ -11,8 +11,16 @@
|
|
|
11
11
|
* ```
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
jsx,
|
|
16
|
+
_resolveReactiveName,
|
|
17
|
+
_resolveBindName,
|
|
18
|
+
_injectedBindKey,
|
|
19
|
+
_renderChild,
|
|
20
|
+
_protectThunks,
|
|
21
|
+
} from "./jsx-runtime.ts";
|
|
15
22
|
import type { HtmlNode } from "./jsx-runtime.ts";
|
|
23
|
+
import { publishSection, reserveSection } from "./sections.ts";
|
|
16
24
|
import { jsLiteral } from "./utils.ts";
|
|
17
25
|
|
|
18
26
|
// ── <Link> ────────────────────────────────────────────────────────────────
|
|
@@ -936,8 +944,7 @@ export interface PagerProps {
|
|
|
936
944
|
* @remarks
|
|
937
945
|
* Pass `params` to preserve other query state (search term, per-page, …). `hover`
|
|
938
946
|
* prefetches each page; `prevLabel`/`nextLabel` and the class props override the
|
|
939
|
-
* defaults. This is the links UI
|
|
940
|
-
* `$paginator->links()` view.
|
|
947
|
+
* defaults. This is the links UI for a paginator.
|
|
941
948
|
*
|
|
942
949
|
* @example
|
|
943
950
|
* ```tsx
|
|
@@ -1472,3 +1479,269 @@ export function Drawer(props: DrawerProps): HtmlNode {
|
|
|
1472
1479
|
// and nothing intercepts clicks when closed (both are display:none via x-show).
|
|
1473
1480
|
return jsx("div", { ...rest, class: "contents", children: [backdrop, jsx("div", panelOut)] });
|
|
1474
1481
|
}
|
|
1482
|
+
|
|
1483
|
+
// ── <ErrorBoundary> ───────────────────────────────────────────────────────────
|
|
1484
|
+
/** Props for {@link ErrorBoundary}: the protected `children` and what to show instead on failure. */
|
|
1485
|
+
export interface ErrorBoundaryProps {
|
|
1486
|
+
/** Markup to render when a child fails. A function receives the thrown error. */
|
|
1487
|
+
fallback?: HtmlNode | ((error: unknown) => HtmlNode);
|
|
1488
|
+
/** Called server-side when a child fails, for logging or reporting. */
|
|
1489
|
+
onError?: (error: unknown) => void;
|
|
1490
|
+
/** Wrapper element. Defaults to `"div"`. */
|
|
1491
|
+
tag?: string;
|
|
1492
|
+
class?: string;
|
|
1493
|
+
children?: unknown;
|
|
1494
|
+
[key: string]: unknown;
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
/**
|
|
1498
|
+
* Contains a failure in a nested `<Component />` so it costs that component
|
|
1499
|
+
* rather than the page.
|
|
1500
|
+
*
|
|
1501
|
+
* @remarks
|
|
1502
|
+
* Without a boundary, a child component that throws while mounting or rendering
|
|
1503
|
+
* takes the whole response with it — one broken widget blanks the dashboard.
|
|
1504
|
+
* Wrapped, the child is replaced by `fallback` and everything around it renders.
|
|
1505
|
+
*
|
|
1506
|
+
* What it covers is child *components*. Inline JSX in the same `render()` is evaluated before
|
|
1507
|
+
* this component is ever called, so a throw there cannot be intercepted here —
|
|
1508
|
+
* move the risky work into a child component, which is where it belongs anyway.
|
|
1509
|
+
* Boundaries nest, and the innermost one wins.
|
|
1510
|
+
*
|
|
1511
|
+
* The failure is reported through `onError` and logged; it is contained, not
|
|
1512
|
+
* hidden.
|
|
1513
|
+
*
|
|
1514
|
+
* @example
|
|
1515
|
+
* ```tsx
|
|
1516
|
+
* <ErrorBoundary fallback={<p class="text-sm text-red-600">Sales data unavailable.</p>}>
|
|
1517
|
+
* <SalesReport />
|
|
1518
|
+
* </ErrorBoundary>
|
|
1519
|
+
* ```
|
|
1520
|
+
*
|
|
1521
|
+
* @category Feedback
|
|
1522
|
+
*/
|
|
1523
|
+
export function ErrorBoundary(props: ErrorBoundaryProps): HtmlNode {
|
|
1524
|
+
const { fallback, onError, tag, class: cls, children, ...rest } = props;
|
|
1525
|
+
|
|
1526
|
+
// Children are already rendered, and their child-component thunks already
|
|
1527
|
+
// queued but not yet run, so the tokens sitting in this string are exactly the
|
|
1528
|
+
// pending children this boundary is responsible for.
|
|
1529
|
+
const inner = _renderChild(children);
|
|
1530
|
+
|
|
1531
|
+
_protectThunks(inner, (error) => {
|
|
1532
|
+
try {
|
|
1533
|
+
onError?.(error);
|
|
1534
|
+
} catch {
|
|
1535
|
+
// A reporting hook that throws must not replace the original failure with
|
|
1536
|
+
// a less useful one.
|
|
1537
|
+
}
|
|
1538
|
+
console.error("[Flow] A component inside <ErrorBoundary> failed to render:", error);
|
|
1539
|
+
|
|
1540
|
+
if (typeof fallback === "function") return fallback(error).html;
|
|
1541
|
+
if (fallback) return fallback.html;
|
|
1542
|
+
return (
|
|
1543
|
+
'<div data-flow-boundary-error role="alert" class="rounded border border-red-300 ' +
|
|
1544
|
+
'bg-red-50 p-3 text-sm text-red-700">Something went wrong loading this section.</div>'
|
|
1545
|
+
);
|
|
1546
|
+
});
|
|
1547
|
+
|
|
1548
|
+
const out: Record<string, unknown> = { ...rest, children: { html: inner } };
|
|
1549
|
+
out["data-flow-boundary"] = true;
|
|
1550
|
+
if (cls) out["class"] = cls;
|
|
1551
|
+
return jsx(tag ?? "div", out);
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
// ── <SectionContent> / <SectionOutlet> ────────────────────────────────────────
|
|
1555
|
+
/** Props for {@link SectionContent}: the `name` to publish under, and the markup. */
|
|
1556
|
+
export interface SectionContentProps {
|
|
1557
|
+
/** Section name. Must match a {@link SectionOutlet}. */
|
|
1558
|
+
name: string;
|
|
1559
|
+
children?: unknown;
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
/**
|
|
1563
|
+
* Publish markup to a named section, rendered wherever the matching
|
|
1564
|
+
* {@link SectionOutlet} sits — typically in the layout.
|
|
1565
|
+
*
|
|
1566
|
+
* @remarks
|
|
1567
|
+
* This renders nothing where it appears. It is how a page reaches a region it
|
|
1568
|
+
* does not own: a page-specific toolbar button, a breadcrumb trail, a title. The
|
|
1569
|
+
* alternative is threading content through every component in between as props,
|
|
1570
|
+
* or the layout knowing about every page that might contribute.
|
|
1571
|
+
*
|
|
1572
|
+
* Two components may publish to one name; their content accumulates in render
|
|
1573
|
+
* order rather than one replacing the other.
|
|
1574
|
+
*
|
|
1575
|
+
* Sections resolve once per document render. A WebSocket patch re-renders a
|
|
1576
|
+
* component, not the layout, so content published during one does not reach an
|
|
1577
|
+
* outlet outside the component being patched.
|
|
1578
|
+
*
|
|
1579
|
+
* @example
|
|
1580
|
+
* ```tsx
|
|
1581
|
+
* <SectionContent name="toolbar">
|
|
1582
|
+
* <button onClick={this.publish}>Publish</button>
|
|
1583
|
+
* </SectionContent>
|
|
1584
|
+
* ```
|
|
1585
|
+
*
|
|
1586
|
+
* @category Layout
|
|
1587
|
+
*/
|
|
1588
|
+
export function SectionContent(props: SectionContentProps): HtmlNode {
|
|
1589
|
+
publishSection(props.name, _renderChild(props.children));
|
|
1590
|
+
return { html: "" };
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
/** Props for {@link SectionOutlet}: the `name` to render, plus optional default content. */
|
|
1594
|
+
export interface SectionOutletProps {
|
|
1595
|
+
/** Section name. Must match a {@link SectionContent}. */
|
|
1596
|
+
name: string;
|
|
1597
|
+
/** Rendered when nothing was published to this section. */
|
|
1598
|
+
children?: unknown;
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
/**
|
|
1602
|
+
* Render whatever was published to a named section.
|
|
1603
|
+
*
|
|
1604
|
+
* @remarks
|
|
1605
|
+
* Place it in a layout to give pages a hole to fill. Children are the default,
|
|
1606
|
+
* used when no page published anything.
|
|
1607
|
+
*
|
|
1608
|
+
* Order does not matter: the outlet reserves its place and is filled after the
|
|
1609
|
+
* page *and* the layout have rendered. That is what makes the usual arrangement
|
|
1610
|
+
* work — outlet in the layout, content in the page it wraps — where reading a
|
|
1611
|
+
* store at render time would always be too early.
|
|
1612
|
+
*
|
|
1613
|
+
* @example
|
|
1614
|
+
* ```tsx
|
|
1615
|
+
* <header class="flex gap-2">
|
|
1616
|
+
* <SectionOutlet name="toolbar" />
|
|
1617
|
+
* </header>
|
|
1618
|
+
* ```
|
|
1619
|
+
*
|
|
1620
|
+
* @category Layout
|
|
1621
|
+
*/
|
|
1622
|
+
export function SectionOutlet(props: SectionOutletProps): HtmlNode {
|
|
1623
|
+
return { html: reserveSection(props.name, _renderChild(props.children)) };
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
// ── <Virtualize> ──────────────────────────────────────────────────────────────
|
|
1627
|
+
/** Props for {@link Virtualize}: the current window of rows and how to ask for another. */
|
|
1628
|
+
export interface VirtualizeProps<T> {
|
|
1629
|
+
/** The rows to render — the current window, not the whole collection. */
|
|
1630
|
+
items: readonly T[];
|
|
1631
|
+
/** Height of one row in pixels. Every row must be this tall. */
|
|
1632
|
+
itemHeight: number;
|
|
1633
|
+
/** Total rows in the full collection. Defaults to `items.length`. */
|
|
1634
|
+
total?: number;
|
|
1635
|
+
/** Index of `items[0]` within the full collection. Defaults to 0. */
|
|
1636
|
+
start?: number;
|
|
1637
|
+
/** Height of the scroll viewport in pixels. Defaults to 400. */
|
|
1638
|
+
height?: number;
|
|
1639
|
+
/**
|
|
1640
|
+
* Server action requesting a different window, called as `(start, count)`.
|
|
1641
|
+
* Omit when the collection is small enough to render whole.
|
|
1642
|
+
*/
|
|
1643
|
+
onWindow?: unknown;
|
|
1644
|
+
/** Rows rendered beyond the viewport on each side, to hide fetch latency. Defaults to 6. */
|
|
1645
|
+
overscan?: number;
|
|
1646
|
+
class?: string;
|
|
1647
|
+
/** The row template: `(item, index) => <div>…</div>`, where `index` is absolute. */
|
|
1648
|
+
children: (item: T, index: number) => HtmlNode;
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
/**
|
|
1652
|
+
* A scrolling window over a collection too large to put in the DOM.
|
|
1653
|
+
*
|
|
1654
|
+
* @remarks
|
|
1655
|
+
* Only the visible rows exist as elements; spacers above and below hold the
|
|
1656
|
+
* scrollbar at the size the full collection implies. As the viewport moves, the
|
|
1657
|
+
* component asks the server for the window it now needs via `onWindow` — a
|
|
1658
|
+
* server-authoritative arrangement, which means the collection never has to
|
|
1659
|
+
* reach the client in full.
|
|
1660
|
+
*
|
|
1661
|
+
* Rows must be a known, uniform `itemHeight`. That is what lets a scroll offset
|
|
1662
|
+
* be turned into an index without measuring anything.
|
|
1663
|
+
*
|
|
1664
|
+
* Distinct from {@link InfiniteScroll}, which appends and grows the DOM without
|
|
1665
|
+
* bound. Reach for this when *keeping* every rendered row is the problem.
|
|
1666
|
+
*
|
|
1667
|
+
* @example
|
|
1668
|
+
* ```tsx
|
|
1669
|
+
* <Virtualize
|
|
1670
|
+
* items={this.window}
|
|
1671
|
+
* start={this.windowStart}
|
|
1672
|
+
* total={this.total}
|
|
1673
|
+
* itemHeight={36}
|
|
1674
|
+
* height={480}
|
|
1675
|
+
* onWindow={this.loadWindow}
|
|
1676
|
+
* >
|
|
1677
|
+
* {(row) => <div class="h-9 px-3 leading-9">{row.name}</div>}
|
|
1678
|
+
* </Virtualize>
|
|
1679
|
+
* ```
|
|
1680
|
+
*
|
|
1681
|
+
* @category Navigation & data
|
|
1682
|
+
*/
|
|
1683
|
+
export function Virtualize<T>(props: VirtualizeProps<T>): HtmlNode {
|
|
1684
|
+
const {
|
|
1685
|
+
items,
|
|
1686
|
+
itemHeight,
|
|
1687
|
+
total,
|
|
1688
|
+
start = 0,
|
|
1689
|
+
height = 400,
|
|
1690
|
+
onWindow,
|
|
1691
|
+
overscan = 6,
|
|
1692
|
+
class: cls,
|
|
1693
|
+
children,
|
|
1694
|
+
} = props;
|
|
1695
|
+
|
|
1696
|
+
const rows = items ?? [];
|
|
1697
|
+
const count = total ?? rows.length;
|
|
1698
|
+
const visible = Math.max(1, Math.ceil(height / itemHeight));
|
|
1699
|
+
|
|
1700
|
+
let body = "";
|
|
1701
|
+
for (let i = 0; i < rows.length; i++) {
|
|
1702
|
+
const node = children(rows[i]!, start + i);
|
|
1703
|
+
body += node && typeof node === "object" && "html" in node ? node.html : "";
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
// Spacers rather than absolutely-positioned rows: the browser keeps the
|
|
1707
|
+
// scrollbar honest without every row needing a computed `top`, and the row
|
|
1708
|
+
// template stays ordinary markup the caller can style however they like.
|
|
1709
|
+
const above = start * itemHeight;
|
|
1710
|
+
const below = Math.max(0, (count - start - rows.length) * itemHeight);
|
|
1711
|
+
|
|
1712
|
+
const state =
|
|
1713
|
+
`{ h: ${itemHeight}, v: ${visible}, o: ${overscan}, at: ${start}, ` +
|
|
1714
|
+
`win() { const s = Math.max(0, Math.floor($el.scrollTop / this.h) - this.o); ` +
|
|
1715
|
+
`const c = this.v + this.o * 2; ` +
|
|
1716
|
+
// Only ask when the window actually moved: scroll fires far more often than
|
|
1717
|
+
// the set of needed rows changes, and every request is a round trip.
|
|
1718
|
+
`if (s === this.at) return; this.at = s; ${_virtualizeRequest(onWindow)} } }`;
|
|
1719
|
+
|
|
1720
|
+
return jsx("div", {
|
|
1721
|
+
...(cls ? { class: cls } : {}),
|
|
1722
|
+
"x-data": state,
|
|
1723
|
+
"x-on:scroll.passive.throttle.100ms": "win()",
|
|
1724
|
+
style: `height:${height}px;overflow-y:auto`,
|
|
1725
|
+
"data-flow-virtualize": true,
|
|
1726
|
+
children: {
|
|
1727
|
+
html:
|
|
1728
|
+
`<div style="height:${above}px" data-flow-virtualize-spacer></div>` +
|
|
1729
|
+
body +
|
|
1730
|
+
`<div style="height:${below}px" data-flow-virtualize-spacer></div>`,
|
|
1731
|
+
},
|
|
1732
|
+
});
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
/**
|
|
1736
|
+
* The client expression that asks for a new window.
|
|
1737
|
+
*
|
|
1738
|
+
* A method reference arrives as a function whose `name` is the server action to
|
|
1739
|
+
* call. Anything else means no provider was supplied, in which case scrolling
|
|
1740
|
+
* just moves over rows that are already present.
|
|
1741
|
+
*/
|
|
1742
|
+
function _virtualizeRequest(onWindow: unknown): string {
|
|
1743
|
+
if (typeof onWindow !== "function") return "";
|
|
1744
|
+
const method = (onWindow as { name?: string }).name;
|
|
1745
|
+
if (!method) return "";
|
|
1746
|
+
return `$flow.${method}(s, c);`;
|
|
1747
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -12,8 +12,8 @@ export interface FlowConfigShape {
|
|
|
12
12
|
*/
|
|
13
13
|
cspSafe: boolean;
|
|
14
14
|
/**
|
|
15
|
-
* Global middleware re-applied on every WebSocket update (
|
|
16
|
-
*
|
|
15
|
+
* Global middleware re-applied on every WebSocket update (persistent
|
|
16
|
+
* middleware). Entries are matched against the app's global
|
|
17
17
|
* pipeline by class reference or by class name.
|
|
18
18
|
*
|
|
19
19
|
* Route middleware always re-runs and does not need to be listed here.
|