@zerotal/flow 1.4.0 → 1.5.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.
@@ -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 (matching Livewire's `wire:model.live` default). Discrete
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("[Flow] WebSocket unavailable — falling back to HTTP requests.");
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) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
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
- console.error("[Flow] HTTP action failed:", e);
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
- // A compact, dev-only scrubbing panel docked bottom-left. The console API above is the primary
1210
- // interface; this is a convenience over it.
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;left:10px;top:88px;z-index:2147483647;font:12px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace;color:#e5e7eb}
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{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)}
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 (Livewire behavior).
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 mirrors Livewire's $wire API:
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 locally (sent with next action)
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
  });
@@ -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 ? this.hide() : this.show();
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 { jsx, _resolveReactiveName, _resolveBindName, _injectedBindKey } from "./jsx-runtime.ts";
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 — the Flow equivalent of Livewire's
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 (Livewire-style
16
- * persistent middleware). Entries are matched against the app's global
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.