@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/CHANGELOG.md +317 -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 +345 -38
- package/src/client/headless.ts +2 -1
- package/src/compiler/directives.ts +1 -0
- package/src/compiler/transform.ts +4 -0
- package/src/components.ts +286 -4
- 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 +90 -7
- 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/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> ────────────────────────────────────────────────────────────────
|
|
@@ -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
|
|
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 (
|
|
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.
|
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
|
-
|
|
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
|
-
//
|
|
234
|
-
//
|
|
235
|
-
let
|
|
236
|
-
|
|
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
|
-
|
|
239
|
-
let
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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 (
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
*
|
|
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.
|
|
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.
|
|
684
|
-
*
|
|
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.
|
|
704
|
-
//
|
|
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).
|
|
713
|
-
*
|
|
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:
|
|
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
|
|
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
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";
|