@zerotal/flow 1.3.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.
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> ────────────────────────────────────────────────────────────────
@@ -22,6 +30,7 @@ export interface LinkProps {
22
30
  hover?: boolean;
23
31
  current?: boolean;
24
32
  exact?: boolean;
33
+ preserveScroll?: boolean;
25
34
  [key: string]: unknown;
26
35
  }
27
36
 
@@ -35,18 +44,26 @@ export interface LinkProps {
35
44
  * the automatic active state to an exact path match. Any other props pass through to
36
45
  * the `<a>`.
37
46
  *
47
+ * Following a link lands at the top of the new page, as a full navigation would —
48
+ * or at the fragment, if the href names one. `preserveScroll` leaves the viewport
49
+ * where it is instead, which is what you want for a control the user is looking at
50
+ * partway down a page (a filter, a sort header, a tab strip) rather than a link
51
+ * that takes them somewhere else.
52
+ *
38
53
  * @example
39
54
  * ```tsx
40
55
  * <Link href="/posts" hover>Posts</Link>
41
56
  * <Link href="/dashboard" exact>Dashboard</Link>
57
+ * <Link href="/posts?sort=title" preserveScroll>Title</Link>
42
58
  * ```
43
59
  *
44
60
  * @category Navigation & data
45
61
  */
46
62
  export function Link(props: LinkProps & { children?: unknown }): HtmlNode {
47
- const { href, hover, current, exact, children, ...rest } = props;
63
+ const { href, hover, current, exact, preserveScroll, children, ...rest } = props;
48
64
  const out: Record<string, unknown> = { ...rest, href, navigate: true, children };
49
65
  if (hover) out["navigateHover"] = true;
66
+ if (preserveScroll) out["navigatePreserveScroll"] = true;
50
67
  // `current` overrides the automatic URL-based active state:
51
68
  // false → never active (flow:current.ignore)
52
69
  // true → always active, and stays active across SPA navigations (flow:current.force).
@@ -927,8 +944,7 @@ export interface PagerProps {
927
944
  * @remarks
928
945
  * Pass `params` to preserve other query state (search term, per-page, …). `hover`
929
946
  * prefetches each page; `prevLabel`/`nextLabel` and the class props override the
930
- * defaults. This is the links UI — the Flow equivalent of Livewire's
931
- * `$paginator->links()` view.
947
+ * defaults. This is the links UI for a paginator.
932
948
  *
933
949
  * @example
934
950
  * ```tsx
@@ -1463,3 +1479,269 @@ export function Drawer(props: DrawerProps): HtmlNode {
1463
1479
  // and nothing intercepts clicks when closed (both are display:none via x-show).
1464
1480
  return jsx("div", { ...rest, class: "contents", children: [backdrop, jsx("div", panelOut)] });
1465
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.
package/src/decorators.ts CHANGED
@@ -189,13 +189,26 @@ function _scanChain(startProto: object | null): void {
189
189
  interface PendingField {
190
190
  name: string;
191
191
  apply: (proto: object) => void;
192
+ /**
193
+ * `context.metadata` — one object per **file**, not per class (Bun shares it across
194
+ * every class defined in the same module). It is the only link back to where a
195
+ * buffered entry came from, because `Symbol.metadata` never reaches the class, so
196
+ * there is nothing to read at drain time.
197
+ *
198
+ * It is enough for the case that matters: a class may only claim registrations
199
+ * enqueued by its own file. Without it, a component that declares `count` and is
200
+ * never read leaves that entry in the buffer forever, and the next class in a
201
+ * *different* file with an own field called `count` claims it — silently getting
202
+ * the wrong decorator, and never getting its own.
203
+ */
204
+ group: object | undefined;
192
205
  }
193
206
  const _pendingFields: PendingField[] = [];
194
207
  const _drainedProtos = new WeakSet<object>();
195
208
 
196
209
  /** Enqueue a field registration captured (with the correct name) in the decorator body. */
197
- function _enqueueField(name: string, apply: (proto: object) => void): void {
198
- _pendingFields.push({ name, apply });
210
+ function _enqueueField(name: string, apply: (proto: object) => void, group?: object): void {
211
+ _pendingFields.push({ name, apply, group });
199
212
  }
200
213
 
201
214
  /**
@@ -206,6 +219,11 @@ function _enqueueField(name: string, apply: (proto: object) => void): void {
206
219
  * decorators run consecutively at definition time (including a subclass's mixin-supplied fields)
207
220
  * and in declaration order, so its block is exactly such a run.
208
221
  *
222
+ * The search is scoped to one FILE's entries at a time, newest first — see
223
+ * {@link PendingField.group}. Scanning the whole buffer let a class claim a same-named
224
+ * entry left behind by an unrelated file, which is invisible: it gets the wrong
225
+ * decorator and never gets its own.
226
+ *
209
227
  * The strictly-increasing constraint is what makes this robust when two classes share field
210
228
  * names AND their blocks are adjacent in the buffer (e.g. AddTransactionModal and TransferModal
211
229
  * both declare `accountId`/`open`/`fAmount`). A plain "longest run ⊆ own" would greedily extend
@@ -222,40 +240,109 @@ function _drainFields(ctor: unknown): void {
222
240
  _drainedProtos.add(proto); // mark first to stay re-entrancy-safe during the probe
223
241
  if (_pendingFields.length === 0) return;
224
242
 
243
+ // The fields THIS class declares — not the ones it inherits. Instance fields all
244
+ // land on the instance whoever declared them, so `Object.keys(new Derived())` also
245
+ // lists the base's; subtracting the base's own probe leaves this class's block.
246
+ //
247
+ // The difference is load-bearing, not tidiness. `PaginatedPage extends
248
+ // Pagination(Component)` inherits `page`, so an undrained `[page, page]` group left
249
+ // by *another* `Pagination(...)` application matched it — and being two entries long
250
+ // it beat `PaginatedPage`'s own one-entry `[rows]`. The subclass silently ended up
251
+ // with the mixin's decorators and none of its own.
252
+ //
253
+ // If the base cannot be constructed without arguments there is nothing to subtract,
254
+ // and the full key set is the same answer this used to give.
225
255
  let ownIndex: Map<string, number>;
226
256
  try {
227
257
  const keys = Object.keys(new (ctor as new () => object)());
228
- ownIndex = new Map(keys.map((k, i) => [k, i]));
258
+ const base = Object.getPrototypeOf(ctor) as unknown;
259
+ let inherited: Set<string> | undefined;
260
+ if (typeof base === "function" && base !== Function.prototype) {
261
+ try {
262
+ inherited = new Set(Object.keys(new (base as new () => object)()));
263
+ } catch {
264
+ inherited = undefined;
265
+ }
266
+ }
267
+ const declared = inherited ? keys.filter((k) => !inherited.has(k)) : keys;
268
+ ownIndex = new Map(declared.map((k, i) => [k, i]));
229
269
  } catch {
230
270
  return; // not constructible without args — nothing we can match
231
271
  }
232
272
 
233
- // Longest contiguous run whose names map to strictly-increasing declaration-order indices
234
- // (equal allowed only for an identical repeated name — multiple decorators on one field).
235
- let bestStart = -1;
236
- let bestLen = 0;
273
+ // Match within one class's entries at a time — see {@link PendingField.group}.
274
+ //
275
+ // Scanning the buffer as one flat list let a class claim a same-named entry left
276
+ // behind by an unrelated class, and that is invisible when it happens: the class
277
+ // silently gets the wrong decorator and never gets its own. It is not
278
+ // hypothetical — a component that declares a field and is never read leaves its
279
+ // entry buffered for the life of the process, and names like `count`, `open` and
280
+ // `page` are declared all over a codebase.
281
+ //
282
+ // Groups are keyed by identity rather than by adjacency, because a class's entries
283
+ // need not be contiguous: an import evaluated part-way through a module interleaves
284
+ // another file's.
285
+ const groups: object[] = [];
286
+ const byGroup = new Map<object, number[]>();
287
+ const NO_GROUP = {}; // stand-in key, so entries without metadata still group together
237
288
  for (let i = 0; i < _pendingFields.length; i++) {
238
- if (!ownIndex.has(_pendingFields[i]!.name)) continue;
239
- let last = -1;
240
- let lastName = "";
241
- let j = i;
242
- while (j < _pendingFields.length) {
243
- const name = _pendingFields[j]!.name;
244
- const idx = ownIndex.get(name);
245
- if (idx === undefined) break;
246
- if (idx < last || (idx === last && name !== lastName)) break;
247
- last = idx;
248
- lastName = name;
249
- j++;
289
+ const key = _pendingFields[i]!.group ?? NO_GROUP;
290
+ let list = byGroup.get(key);
291
+ if (!list) {
292
+ list = [];
293
+ byGroup.set(key, list);
294
+ groups.push(key);
250
295
  }
251
- if (j - i > bestLen) {
252
- bestLen = j - i;
253
- bestStart = i;
296
+ list.push(i);
297
+ }
298
+
299
+ // Best run across every group, NOT the first group that matches anything. A class
300
+ // whose own group is a two-field block must beat an unrelated group that happens to
301
+ // share one of those names, and only the length says so. Ties go to the most
302
+ // recently enqueued group: a class is drained after its own definition, so among
303
+ // equal candidates the newest is the one that just defined it, and older equals are
304
+ // leftovers by construction.
305
+ let bestIdxs: number[] | null = null;
306
+ let bestLen = 0;
307
+ let bestGroup = -1;
308
+ for (let g = 0; g < groups.length; g++) {
309
+ const idxs = byGroup.get(groups[g]!)!;
310
+
311
+ // Longest contiguous run whose names map to strictly-increasing declaration-order
312
+ // indices (equal allowed only for an identical repeated name — multiple decorators
313
+ // on one field). "Contiguous" is within this group's entries.
314
+ for (let i = 0; i < idxs.length; i++) {
315
+ if (!ownIndex.has(_pendingFields[idxs[i]!]!.name)) continue;
316
+ let last = -1;
317
+ let lastName = "";
318
+ let j = i;
319
+ while (j < idxs.length) {
320
+ const name = _pendingFields[idxs[j]!]!.name;
321
+ const idx = ownIndex.get(name);
322
+ if (idx === undefined) break;
323
+ if (idx < last || (idx === last && name !== lastName)) break;
324
+ last = idx;
325
+ lastName = name;
326
+ j++;
327
+ }
328
+ // Longer always wins. On a tie, a LATER group wins (newest definition), but an
329
+ // earlier run within the SAME group is kept — which is the previous behaviour.
330
+ const len = j - i;
331
+ if (len > bestLen || (len === bestLen && len > 0 && g > bestGroup)) {
332
+ bestLen = len;
333
+ bestIdxs = idxs.slice(i, j);
334
+ bestGroup = g;
335
+ }
254
336
  }
255
337
  }
256
- if (bestStart === -1) return;
257
- const claimed = _pendingFields.splice(bestStart, bestLen);
258
- for (const e of claimed) e.apply(proto);
338
+ if (!bestIdxs || bestLen === 0) return;
339
+
340
+ // Apply, then remove the claimed entries. Removal is by descending buffer index so
341
+ // the earlier positions stay valid while splicing.
342
+ for (const bufIdx of bestIdxs) _pendingFields[bufIdx]!.apply(proto);
343
+ for (let k = bestIdxs.length - 1; k >= 0; k--) {
344
+ _pendingFields.splice(bestIdxs[k]!, 1);
345
+ }
259
346
  }
260
347
 
261
348
  /** Drain via an instance or a prototype before reading. */
@@ -291,7 +378,7 @@ function _fieldDecorator(
291
378
  return (_value, context) => {
292
379
  const name = String(context.name);
293
380
  _assertField(name, context);
294
- _enqueueField(name, (proto) => register(proto, name));
381
+ _enqueueField(name, (proto) => register(proto, name), context.metadata);
295
382
  };
296
383
  }
297
384
 
@@ -415,7 +502,7 @@ function _memoizeGetter(orig: () => unknown, name: string): () => unknown {
415
502
  * @remarks
416
503
  * A computed getter is not serialized and is not client-writable; it renders as static
417
504
  * server-produced text. The result is memoized for the duration of a single render pass
418
- * (Livewire-style per-request caching): an expensive getter read several times in one template
505
+ * — an expensive getter read several times in one template
419
506
  * runs once. The cache is scoped to the active render (see `_renderFlowPage`), so reads
420
507
  * outside rendering always recompute and never go stale. Apply only to a getter — a field-style
421
508
  * decorator on it is rejected; conversely `@computed` on a non-getter is a TypeScript error.
@@ -512,7 +599,7 @@ export function expose(_value: unknown, context: ExposeContext): void {
512
599
  }
513
600
  if (context.kind === "field") {
514
601
  // Field: capture in the body and enqueue (see _fieldDecorator / _drainFields).
515
- _enqueueField(name, (proto) => _register(_exposedProps, proto, name));
602
+ _enqueueField(name, (proto) => _register(_exposedProps, proto, name), context.metadata);
516
603
  return;
517
604
  }
518
605
  // Method: tag the function so `_scanProto` finds it on the DECLARING prototype (see header);
@@ -671,7 +758,7 @@ export function resolveSharedChannel(channel: SharedChannel, instance: object):
671
758
  // A child-component prop the PARENT can push new values into on every re-render.
672
759
  // Included in the snapshot and client-writable (so the framework can deliver
673
760
  // parent updates), and re-emitted by the parent so the child re-renders when the
674
- // bound value changes. Mirrors Livewire's #[Reactive].
761
+ // bound value changes.
675
762
 
676
763
  /**
677
764
  * Marks a child-component prop as reactive — the parent re-pushes new values into it on every
@@ -680,8 +767,8 @@ export function resolveSharedChannel(channel: SharedChannel, instance: object):
680
767
  * @remarks
681
768
  * The prop is included in the snapshot and is client-writable (so the framework can deliver the
682
769
  * parent's updates), and the parent re-emits it so the child re-renders when the bound value
683
- * changes. This is a one-way parent→child binding; use `@modelable` for two-way. Mirrors
684
- * Livewire's `#[Reactive]`. Applies to a field only.
770
+ * changes. This is a one-way parent→child binding; use `@modelable` for two-way.
771
+ * Applies to a field only.
685
772
  *
686
773
  * @example
687
774
  * ```tsx
@@ -700,8 +787,8 @@ export const reactive = _fieldDecorator((proto, name) => {
700
787
 
701
788
  // ── @modelable ────────────────────────────────────────────────────────────────
702
789
  // A reactive prop that ALSO syncs back to the parent: `value={this.x}` on a child
703
- // component binds the parent's `x` to the child's modelable prop both ways. Mirrors
704
- // Livewire's #[Modelable]. A modelable prop is implicitly reactive.
790
+ // component binds the parent's `x` to the child's modelable prop both ways.
791
+ // A modelable prop is implicitly reactive.
705
792
 
706
793
  /**
707
794
  * Marks a child-component prop as two-way bound to the parent (parent↔child).
@@ -709,8 +796,8 @@ export const reactive = _fieldDecorator((proto, name) => {
709
796
  * @remarks
710
797
  * A modelable prop is reactive (the parent pushes new values in on re-render) AND syncs back:
711
798
  * `value={this.x}` on a child component binds the parent's `x` to the child's modelable prop in
712
- * both directions. A modelable prop is implicitly reactive (it registers as both). Mirrors
713
- * Livewire's `#[Modelable]`. Applies to a field only.
799
+ * both directions. A modelable prop is implicitly reactive (it registers as both).
800
+ * Applies to a field only.
714
801
  *
715
802
  * @example
716
803
  * ```tsx
package/src/dehydrate.ts CHANGED
@@ -73,7 +73,7 @@ function currentSubject(): string | undefined {
73
73
 
74
74
  // ── Signing payload ───────────────────────────────────────────────────────────
75
75
  // children and listeners are excluded from signing:
76
- // - children: Livewire pattern — ids are derived server-side, never trusted from client
76
+ // - children: ids are derived server-side, never trusted from the client
77
77
  // - listeners: server-derived from class decorators, cannot meaningfully be tampered
78
78
  // with (@on methods are still validated against @expose allowlist on every call)
79
79
 
@@ -155,7 +155,7 @@ export function dehydrate(
155
155
  : undefined;
156
156
 
157
157
  // Children collected during the render that preceded this dehydrate call.
158
- // Excluded from HMAC (Livewire pattern).
158
+ // Excluded from HMAC — see the signing-payload note above.
159
159
  const snapshotMemo: Snapshot["memo"] = {
160
160
  ...memo,
161
161
  children: [...page._childIds],
package/src/events.ts CHANGED
@@ -40,7 +40,7 @@
40
40
  *
41
41
  * @category Realtime
42
42
  */
43
- // eslint-disable-next-line @typescript-eslint/no-empty-interface
43
+
44
44
  export interface FlowEvents {}
45
45
 
46
46
  /**
package/src/index.ts CHANGED
@@ -105,6 +105,10 @@ export {
105
105
  Alert,
106
106
  Table,
107
107
  Drawer,
108
+ ErrorBoundary,
109
+ SectionContent,
110
+ SectionOutlet,
111
+ Virtualize,
108
112
  } from "./components.ts";
109
113
  export type {
110
114
  LinkProps,
@@ -126,6 +130,10 @@ export type {
126
130
  TableProps,
127
131
  TableColumn,
128
132
  DrawerProps,
133
+ ErrorBoundaryProps,
134
+ SectionContentProps,
135
+ SectionOutletProps,
136
+ VirtualizeProps,
129
137
  } from "./components.ts";
130
138
 
131
139
  // Headless (unstyled, accessible) primitives — data-* state, Tailwind-friendly.
@@ -221,8 +229,6 @@ import "./synths/CarbonIntervalSynth.ts";
221
229
  import "./synths/CollectionSynth.ts";
222
230
  import "./synths/DateSynth.ts";
223
231
  import "./synths/TemporaryUploadedFileSynth.ts";
224
- import type { MiddlewareClass, RouteRegistration } from "@zerotal/core";
225
- import type { PageClassWithMeta } from "./registry.ts";
226
232
 
227
233
  // File uploads
228
234
  export { TemporaryUploadedFile } from "./uploads/TemporaryUploadedFile.ts";