@zerotal/flow 1.7.5 → 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -11,6 +11,35 @@ change.
11
11
 
12
12
  ## [Unreleased]
13
13
 
14
+ ## [1.8.0] — 2026-08-24
15
+
16
+ ### Added
17
+
18
+ - **`static interactive = false` — the first render mode.** Every component until now was
19
+ maximally interactive: server-rendered, dehydrated into a snapshot, tracked by the client,
20
+ reachable over a socket. Right for a counter, wasteful for a nav rail. A static component is
21
+ rendered in full by its parent and nothing else — no `onDehydrate`, no snapshot, no state
22
+ script, no entry in the client registry, and no `data-flow-root` (which would freeze it at
23
+ its first render, since its only route to an update is the parent re-rendering it). It takes
24
+ no place in `_childIds` and does not shift its interactive siblings' ids. `lazy`, `defer` and
25
+ `stream` throw rather than being ignored: each waits for the client to ask for the real
26
+ render, and a static child never registers to do the asking. Opt-in — nothing existing
27
+ changes.
28
+
29
+ - **`this.isInteractive`** reports the mode from inside a component. A new public member, so
30
+ the name is no longer available to applications; it is on the documented reserved list.
31
+
32
+ ### Changed
33
+
34
+ - **A page with nothing interactive on it opens no socket.** Every page connected at boot,
35
+ unconditionally — so a marketing page, a docs article or a rendered report held a WebSocket
36
+ open on both ends for the life of the visit to carry nothing, since both paths that write to
37
+ the socket take a `FlowComponent` and none was registered. The connection is now made when
38
+ something needs it: after the initial scan, after an SPA navigation, after a patch registers
39
+ a child. `<Link navigate>` fetches over HTTP, so a static page with links stays disconnected.
40
+ A routed page honours the same static, which is the half that matters — a page is a
41
+ component, and one whose children are static but which is interactive itself still connects.
42
+
14
43
  ## [1.7.3] — 2026-08-20
15
44
 
16
45
  ### Changed
package/api-surface.md CHANGED
@@ -8,6 +8,7 @@
8
8
  class Component = {
9
9
  new (): Component
10
10
  static durable?: boolean | { ttl?: string; scope?: 'user' | 'session';}
11
+ static interactive?: boolean
11
12
  static readonly __isFlowPage: true
12
13
  static title?: string | ((component: never) => string)
13
14
  static using: Compose
@@ -33,6 +34,7 @@ class Component = {
33
34
  _prevChildIds: string[]
34
35
  _redirectStatus: number | null
35
36
  _redirectUrl: string | null
37
+ _renderStaticChild: <C extends Component>(ChildClass: new () => C, opts: { props?: Partial<C>; slots?: Record<string, string>;}) => Promise<string>
36
38
  _resolveTitle: () => string | null
37
39
  _shouldRefresh: boolean
38
40
  _streamSender: ((ref: string, content: string, replace: boolean) => void) | null
@@ -52,6 +54,7 @@ class Component = {
52
54
  errors: ErrorsProxy
53
55
  flash: (message: string, optionsOrLevel?: FlashLevel | FlashOptions) => FlashBuilder
54
56
  hasSlot: (name?: string) => boolean
57
+ isInteractive: boolean
55
58
  layout: (page: HtmlNode) => HtmlNode | Promise<HtmlNode>
56
59
  navigateCurrent: (options?: CurrentUrlOptions) => Promise<void>
57
60
  onBoot: (_ctx?: HttpContext) => Promise<void>
@@ -757,6 +760,7 @@ interface ModalProps = {
757
760
  interface PageClassWithMeta = {
758
761
  new (): Component
759
762
  head?: string
763
+ interactive?: boolean
760
764
  layout?: new () => Layout
761
765
  models?: Record<string, typeof BaseModel>
762
766
  name: string
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/flow",
3
- "version": "1.7.5",
3
+ "version": "1.8.1",
4
4
  "license": "MIT",
5
5
  "maturity": "stable",
6
6
  "private": false,
@@ -47,15 +47,15 @@
47
47
  "@alpinejs/morph": "^3.14.9",
48
48
  "@alpinejs/persist": "^3.14.9",
49
49
  "@alpinejs/resize": "^3.14.9",
50
- "@zerotal/client": "1.7.5",
51
- "@zerotal/core": "1.7.5",
52
- "@zerotal/orm": "1.7.5",
53
- "@zerotal/validator": "1.7.5",
50
+ "@zerotal/client": "1.8.1",
51
+ "@zerotal/core": "1.8.1",
52
+ "@zerotal/orm": "1.8.1",
53
+ "@zerotal/validator": "1.8.1",
54
54
  "alpinejs": "^3.14.9",
55
55
  "typescript": "^5.8.0"
56
56
  },
57
57
  "devDependencies": {
58
- "@zerotal/broadcasting": "1.7.5",
58
+ "@zerotal/broadcasting": "1.8.1",
59
59
  "@types/alpinejs": "^3.13.10",
60
60
  "@types/alpinejs__anchor": "^3.13.1",
61
61
  "@types/alpinejs__collapse": "^3.13.4",
package/src/Component.ts CHANGED
@@ -373,6 +373,24 @@ export interface FlashBuilder {
373
373
  }
374
374
 
375
375
  // ── Component ──────────────────────────────────────────────────────────────────────
376
+ /**
377
+ * Copy a parent's `props` onto a freshly constructed child.
378
+ *
379
+ * Underscore-prefixed keys are skipped: those are the framework's own fields
380
+ * (`_flowId`, `_flowPath`, `_childIds`) and a parent overwriting one would
381
+ * detach the child from its own identity.
382
+ *
383
+ * The cast is the boundary. `Partial<C>` is a checked contract at the call site;
384
+ * inside, assignment is by string key and no index signature exists to express
385
+ * that. Written once here rather than at each of the four render paths, which is
386
+ * both less duplication and three fewer casts.
387
+ */
388
+ function _assignChildProps(target: object, props: Record<string, unknown>): void {
389
+ const bag = target as Record<string, unknown>;
390
+ for (const [key, value] of Object.entries(props)) {
391
+ if (!key.startsWith("_")) bag[key] = value;
392
+ }
393
+ }
376
394
 
377
395
  /**
378
396
  * Base class for Flow pages — reactive server-side components rendered over a WebSocket.
@@ -653,6 +671,53 @@ export abstract class Component {
653
671
  */
654
672
  static title?: string | ((component: never) => string);
655
673
 
674
+ /**
675
+ * Opt a component out of interactivity: `static interactive = false`.
676
+ *
677
+ * Every Flow component today is maximally interactive — rendered on the server,
678
+ * dehydrated into a state snapshot, tracked by the client, and re-rendered over
679
+ * a socket. That is right for a counter and wasteful for a page header, a nav
680
+ * rail or a marketing section, none of which will ever receive an action.
681
+ *
682
+ * A static child is rendered in full by its parent and nothing else: no
683
+ * snapshot, no `<script type="application/json">`, no entry in the client's
684
+ * component registry. It updates when its parent re-renders, the way ordinary
685
+ * markup does.
686
+ *
687
+ * ## Why it is not marked `data-flow-root`
688
+ *
689
+ * The client morph deliberately *skips* nested roots — that is what preserves a
690
+ * live child's DOM and state when its parent re-renders. A static child has no
691
+ * live DOM to preserve, so wearing that attribute would freeze its content at
692
+ * the first render and every later parent update would pass it by. So it is
693
+ * emitted as plain markup, which is also what makes "the parent re-renders it"
694
+ * true without any special case on the client.
695
+ *
696
+ * For the same reason a static child is kept out of `_childIds`: that list is
697
+ * what decides whether a hydrated parent emits a child as an empty stub, and a
698
+ * stub for something with no live DOM blanks the region.
699
+ *
700
+ * ## What it does not do yet
701
+ *
702
+ * The socket is still opened unconditionally, so a page whose every component
703
+ * is static still connects. That is the other half of render modes and it is
704
+ * separate work — see `_connect()` in the client bridge.
705
+ *
706
+ * @category Rendering
707
+ */
708
+ static interactive?: boolean;
709
+
710
+ /**
711
+ * Whether this component was rendered interactively.
712
+ *
713
+ * The instance-side reading of `static interactive`. Useful inside `render()`
714
+ * to leave out something that only makes sense with a client attached — a
715
+ * "Loading…" state, a control whose action can never fire.
716
+ */
717
+ get isInteractive(): boolean {
718
+ return (this.constructor as typeof Component).interactive !== false;
719
+ }
720
+
656
721
  static durable?: boolean | { ttl?: string; scope?: "user" | "session" };
657
722
 
658
723
  /** Set by {@link clearDurable}; the dispatcher deletes the durable entry after the request. @internal */
@@ -1523,6 +1588,31 @@ export abstract class Component {
1523
1588
  // excluded deliberately: those exist to change without remounting the child,
1524
1589
  // and hashing them would trade this bug for a child that resets on every
1525
1590
  // update. The counter still disambiguates genuinely identical siblings.
1591
+ // A static child is markup, not an island: rendered in full by this parent
1592
+ // every time, with no snapshot, no id, and no place in `_childIds`.
1593
+ //
1594
+ // Kept out of that list deliberately. `_childIds`/`_prevChildIds` is what
1595
+ // decides whether a hydrated parent emits a child as an empty stub and lets
1596
+ // the client morph preserve the live DOM underneath — and a static child has
1597
+ // no live DOM, so a stub would blank the region on every parent update. That
1598
+ // single interaction is the whole of what makes render modes delicate.
1599
+ // `Reflect.get` rather than a cast to `typeof Component`, and rather than
1600
+ // widening the parameter to name the static. Widening changes a public
1601
+ // signature — and `C` is inferred from this parameter, which TypeScript does
1602
+ // less reliably through an intersection than through a bare constructor. A
1603
+ // subtly worse inference at every call site is a poor trade for reading one
1604
+ // optional boolean.
1605
+ if (Reflect.get(ChildClass, "interactive") === false) {
1606
+ if (opts.lazy || opts.defer || opts.stream) {
1607
+ throw new Error(
1608
+ `<${name}> is \`static interactive = false\`, so it cannot also be lazy, deferred ` +
1609
+ `or streamed — each of those needs a client to ask for the real render, and a ` +
1610
+ `static child has none. Drop the option, or make the component interactive.`,
1611
+ );
1612
+ }
1613
+ return { html: await this._renderStaticChild(ChildClass, opts) };
1614
+ }
1615
+
1526
1616
  const keyPart =
1527
1617
  opts.key !== undefined
1528
1618
  ? String(opts.key).replace(/[^a-zA-Z0-9_-]/g, "")
@@ -1581,9 +1671,7 @@ export abstract class Component {
1581
1671
  childPage._flowPath = this._flowPath;
1582
1672
  if (opts.slots) childPage._flowSlots = opts.slots;
1583
1673
  if (opts.props) {
1584
- for (const [k, v] of Object.entries(opts.props)) {
1585
- if (!k.startsWith("_")) (childPage as Record<string, unknown>)[k] = v;
1586
- }
1674
+ _assignChildProps(childPage, opts.props as Record<string, unknown>);
1587
1675
  }
1588
1676
  // Don't call onMount() — placeholder only
1589
1677
 
@@ -1616,9 +1704,7 @@ export abstract class Component {
1616
1704
  childPage._flowPath = this._flowPath;
1617
1705
  if (opts.slots) childPage._flowSlots = opts.slots;
1618
1706
  if (opts.props) {
1619
- for (const [k, v] of Object.entries(opts.props)) {
1620
- if (!k.startsWith("_")) (childPage as Record<string, unknown>)[k] = v;
1621
- }
1707
+ _assignChildProps(childPage, opts.props as Record<string, unknown>);
1622
1708
  }
1623
1709
 
1624
1710
  queueStream({
@@ -1651,9 +1737,7 @@ export abstract class Component {
1651
1737
  childPage._flowPath = this._flowPath;
1652
1738
  if (opts.slots) childPage._flowSlots = opts.slots;
1653
1739
  if (opts.props) {
1654
- for (const [k, v] of Object.entries(opts.props)) {
1655
- if (!k.startsWith("_")) (childPage as Record<string, unknown>)[k] = v;
1656
- }
1740
+ _assignChildProps(childPage, opts.props as Record<string, unknown>);
1657
1741
  }
1658
1742
  // A child is populated from its parent's props (assigned above), not from route segments,
1659
1743
  // but it still gets the request context — the same object the routed page received.
@@ -1679,6 +1763,36 @@ export abstract class Component {
1679
1763
  };
1680
1764
  }
1681
1765
 
1766
+ /**
1767
+ * Render a `static interactive = false` child: markup, and nothing else.
1768
+ *
1769
+ * Same lifecycle as an interactive child up to the point where the client would
1770
+ * be involved — `onBoot`, `onMount`, `render` — and then it stops. No
1771
+ * `onDehydrate`, no snapshot, no state script, no wrapper.
1772
+ *
1773
+ * The absent wrapper is the load-bearing part. `data-flow-root` is what tells
1774
+ * the client morph to leave a subtree alone, which is right for something with
1775
+ * live state and exactly wrong here: this child's only route to an update is
1776
+ * its parent re-rendering it, and a skipped subtree would never receive one.
1777
+ */
1778
+ /** @internal Called only by {@link child}. */
1779
+ async _renderStaticChild<C extends Component>(
1780
+ ChildClass: new () => C,
1781
+ opts: { props?: Partial<C>; slots?: Record<string, string> },
1782
+ ): Promise<string> {
1783
+ const childPage = new ChildClass();
1784
+ childPage._flowPath = this._flowPath;
1785
+ if (opts.slots) childPage._flowSlots = opts.slots;
1786
+ if (opts.props) {
1787
+ _assignChildProps(childPage, opts.props as Record<string, unknown>);
1788
+ }
1789
+
1790
+ const ctx = HttpContext.tryGet();
1791
+ await childPage.onBoot(ctx);
1792
+ await childPage.onMount(ctx);
1793
+ return _renderFlowPage(childPage, () => childPage.render());
1794
+ }
1795
+
1682
1796
  // ── Streaming ─────────────────────────────────────────────────────────────
1683
1797
 
1684
1798
  /**
@@ -501,6 +501,8 @@ function _setConnectionState(online: boolean): void {
501
501
  }
502
502
 
503
503
  let _ws: WebSocket | null = null;
504
+ /** Whether this page has ever needed the socket. Latched, never cleared. @see _ensureConnected */
505
+ let _socketWanted = false;
504
506
  let _reconnectDelay = 1_000;
505
507
  const MAX_DELAY = 30_000;
506
508
 
@@ -781,6 +783,37 @@ function _dispatchFrame(comp: FlowComponent, method: string, args: unknown[]): v
781
783
  _sendCall(comp, method, args);
782
784
  }
783
785
 
786
+ /**
787
+ * Open the socket, but only if the page has anything that could use it.
788
+ *
789
+ * Every page used to connect. A page whose components are all
790
+ * `static interactive = false` — a marketing page, a docs article, a rendered
791
+ * report — has nothing that can dispatch an action, poll, or receive a patch,
792
+ * and it still opened `/__flow/ws` and held it. That is a connection per visitor
793
+ * per page, and a server holding all of them, for a page that will never send a
794
+ * frame.
795
+ *
796
+ * The test is `_components.size`, and it is exact rather than a heuristic: both
797
+ * paths that write to the socket — {@link _sendCall} and {@link _cancelTask} —
798
+ * take a `FlowComponent`, so with none registered nothing can send. Navigation
799
+ * does not need it either; `<Link navigate>` fetches the page over HTTP.
800
+ *
801
+ * Called again after anything that can register a component — the initial scan,
802
+ * an SPA navigation, a lazy child arriving — so a page that becomes interactive
803
+ * connects at that moment rather than never. Already-connected is a no-op, and
804
+ * reconnection after a drop stays with {@link _connect}, which does not re-ask
805
+ * the question: a page that has connected once has proved it needs a socket.
806
+ *
807
+ * The latch is a flag rather than `_ws !== null`, because `_ws` is nulled on
808
+ * close while a reconnect is pending — so a scan during that window would have
809
+ * opened a second socket beside the one already on its way back.
810
+ */
811
+ function _ensureConnected(): void {
812
+ if (_socketWanted || _components.size === 0) return;
813
+ _socketWanted = true;
814
+ _connect();
815
+ }
816
+
784
817
  function _connect(): void {
785
818
  const proto = location.protocol === "https:" ? "wss:" : "ws:";
786
819
  _ws = new WebSocket(`${proto}//${location.host}/__flow/ws`);
@@ -1173,6 +1206,7 @@ function _morphComponent(comp: FlowComponent, html: string): void {
1173
1206
  }
1174
1207
 
1175
1208
  _scanComponents();
1209
+ _ensureConnected();
1176
1210
  _cleanupDisconnected();
1177
1211
  _refreshComponentFeatures(comp);
1178
1212
  }
@@ -2908,6 +2942,7 @@ async function _navigateTo(href: string, options: NavigateOptions = {}): Promise
2908
2942
  currentRoot.replaceWith(incomingClone);
2909
2943
  _cleanupDisconnected();
2910
2944
  _scanComponents();
2945
+ _ensureConnected();
2911
2946
  _processHead(doc); // re-apply head from the incoming page (shell stylesheet + page meta)
2912
2947
  // Inside the swap, not after it: the document only has its new height (and
2913
2948
  // its fragment targets) once the root is in place. Inside the View
@@ -4158,7 +4193,7 @@ export function initBridge(): void {
4158
4193
  _checkBootError(); // dev-only: an initial-render throw is embedded on the page → show the overlay
4159
4194
  _scanComponents();
4160
4195
  _processHead(); // hoist any SSR <Head> content into document.head
4161
- _connect();
4196
+ _ensureConnected();
4162
4197
  _setupEventDelegation();
4163
4198
  _setupNavigationLinks();
4164
4199
  _setupModalEscape();
package/src/registry.ts CHANGED
@@ -115,6 +115,12 @@ export interface PageClassWithMeta {
115
115
  title?: string | ((component: never) => string);
116
116
  /** Raw HTML to inject into <head> for this page. */
117
117
  head?: string;
118
+ /**
119
+ * `false` renders this page as markup: no snapshot, no state script, no client
120
+ * registration — and, if nothing else on the page registers either, no
121
+ * WebSocket. See {@link Component.interactive}.
122
+ */
123
+ interactive?: boolean;
118
124
  /** Optional model associations for auto-registration. */
119
125
  models?: Record<string, typeof import("@zerotal/orm").BaseModel>;
120
126
  /** Optional layout class. */
package/src/router.ts CHANGED
@@ -174,23 +174,36 @@ export function _makeFlowHandler(path: string, PageClass: PageClassWithMeta) {
174
174
 
175
175
  const innerHtml = await _renderFlowPage(page, () => page.render());
176
176
 
177
+ // A page can opt out of interactivity as a child can — and it is the page
178
+ // that matters most, because the client connects when it finds anything to
179
+ // register. A page that is static and whose children are static registers
180
+ // nothing, so no socket is opened at all.
181
+ const interactive = PageClass.interactive !== false;
182
+
177
183
  // dehydrate() hook runs at the end of the request, before serialisation.
178
- await page.onDehydrate();
179
- const snapshot = dehydrate(page, { id: compId, name: compName, path });
180
- warnIfLarge(snapshot, compName);
184
+ if (interactive) await page.onDehydrate();
185
+ const snapshot = interactive
186
+ ? dehydrate(page, { id: compId, name: compName, path })
187
+ : undefined;
188
+ if (snapshot) warnIfLarge(snapshot, compName);
181
189
 
182
190
  // Persist the durable snapshot (or clear it if clearDurable() was called). No-op unless
183
191
  // the component opted into `static durable`.
184
- await persistDurable(page, ctx, snapshot);
192
+ if (snapshot) await persistDurable(page, ctx, snapshot);
185
193
 
186
194
  // toScriptJson, not JSON.stringify: the snapshot carries @expose/@locked values and
187
195
  // @url props seeded from the query string, so it routinely holds attacker-controlled
188
196
  // strings that would otherwise close the <script> island. See toScriptJson's docblock.
189
- const snapshotJson = toScriptJson(snapshot);
197
+ const snapshotJson = snapshot ? toScriptJson(snapshot) : "";
190
198
 
191
199
  // The flow component root — always the same wrapper regardless of layout.
200
+ // A static page is not marked as a flow root: the attribute is what makes
201
+ // the client morph skip a subtree, and it is also what `_scanComponents`
202
+ // looks for. Neither applies to markup that will never be patched.
192
203
  const flowRoot: HtmlNode = {
193
- html: `<div data-flow-root x-data="{}" data-flow-id="${compId}" data-flow-name="${compName}">${innerHtml}</div>`,
204
+ html: interactive
205
+ ? `<div data-flow-root x-data="{}" data-flow-id="${compId}" data-flow-name="${compName}">${innerHtml}</div>`
206
+ : innerHtml,
194
207
  };
195
208
 
196
209
  // Layout resolution. The JSX-native `layout(page)` instance hook wins; otherwise
@@ -244,7 +257,7 @@ export function _makeFlowHandler(path: string, PageClass: PageClassWithMeta) {
244
257
  </head>
245
258
  <body>
246
259
  ${bodyContent}
247
- <script type="application/json" id="flow-state-${compId}">${snapshotJson}</script>
260
+ ${snapshotJson ? `<script type="application/json" id="flow-state-${compId}">${snapshotJson}</script>` : ""}
248
261
  ${runtimeTag}
249
262
  </body>
250
263
  </html>`;