@zerotal/flow 1.7.4 → 1.8.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 CHANGED
@@ -11,8 +11,49 @@ 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
 
45
+ ### Changed
46
+
47
+ - **BREAKING — `this.title(…)` is removed.** Declare `static title` instead — a string, or a
48
+ function of the component: `static title = (c: SearchPage) => \`Search: ${c.query}\``. It is
49
+ resolved on the server for every render and every patch, so a title that depends on state
50
+ follows it without an action remembering to update it. `_titleValue` and the per-frame reset
51
+ go with it.
52
+
53
+ A call to `this.title(…)` on a component that declares its own `title` field now sets that
54
+ field rather than the document title, which is silent — search for `this.title(` before
55
+ upgrading.
56
+
16
57
  ### Fixed
17
58
 
18
59
  - **A bound password field silently discarded every keystroke.** The client-writable set was
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>
@@ -281,8 +284,6 @@ function registerComponent = (PageClass: typeof Component, path?: string) => voi
281
284
 
282
285
  function registerFlowEvent = <K extends EventName>(name: K, guard: (payload: unknown) => payload is FlowEvents[K]) => void
283
286
 
284
- function registerFlowModel = (name: string, ModelClass: typeof BaseModel) => void
285
-
286
287
  function registerForm = (FormClass: new () => Form) => void
287
288
 
288
289
  function registerSynth = (synth: Synth) => void
@@ -759,6 +760,7 @@ interface ModalProps = {
759
760
  interface PageClassWithMeta = {
760
761
  new (): Component
761
762
  head?: string
763
+ interactive?: boolean
762
764
  layout?: new () => Layout
763
765
  models?: Record<string, typeof BaseModel>
764
766
  name: string
@@ -1089,12 +1091,8 @@ function __escAttr = (v: unknown) => string
1089
1091
 
1090
1092
  ## ./store `(./src/store.ts)`
1091
1093
 
1092
- function clientStore = () => Record<string, unknown>
1093
-
1094
1094
  function defineStore = (initial: Record<string, unknown>) => void
1095
1095
 
1096
- function initClientStore = (alpine: AlpineStores) => Record<string, unknown>
1097
-
1098
1096
  interface FlowStore = {}
1099
1097
 
1100
1098
  ## ./testing `(./src/testing.ts)`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/flow",
3
- "version": "1.7.4",
3
+ "version": "1.8.0",
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.4",
51
- "@zerotal/core": "1.7.4",
52
- "@zerotal/orm": "1.7.4",
53
- "@zerotal/validator": "1.7.4",
50
+ "@zerotal/client": "1.8.0",
51
+ "@zerotal/core": "1.8.0",
52
+ "@zerotal/orm": "1.8.0",
53
+ "@zerotal/validator": "1.8.0",
54
54
  "alpinejs": "^3.14.9",
55
55
  "typescript": "^5.8.0"
56
56
  },
57
57
  "devDependencies": {
58
- "@zerotal/broadcasting": "1.7.4",
58
+ "@zerotal/broadcasting": "1.8.0",
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
  }
@@ -2612,6 +2646,23 @@ function _linkActive(href: string, path: string, exact = false): boolean {
2612
2646
  return h === path || (h.length > 1 && path.startsWith(h + "/"));
2613
2647
  }
2614
2648
 
2649
+ /**
2650
+ * Mark a navigate link as the current page, for both kinds of reader.
2651
+ *
2652
+ * `data-current` styles it; `aria-current` is the only way anyone using a screen
2653
+ * reader learns which of thirty nav items is the page they are on. Setting one
2654
+ * without the other marks the link only for people who can see it.
2655
+ */
2656
+ function _mark(el: Element, active: boolean): void {
2657
+ if (active) {
2658
+ el.setAttribute("data-current", "");
2659
+ el.setAttribute("aria-current", "page");
2660
+ } else {
2661
+ el.removeAttribute("data-current");
2662
+ el.removeAttribute("aria-current");
2663
+ }
2664
+ }
2665
+
2615
2666
  function _updateCurrentLinks(): void {
2616
2667
  const path = window.location.pathname;
2617
2668
 
@@ -2623,13 +2674,11 @@ function _updateCurrentLinks(): void {
2623
2674
  document.querySelectorAll("a[flow\\:navigate]").forEach((el) => {
2624
2675
  if (el.hasAttribute("flow:current.ignore")) return;
2625
2676
  if (el.hasAttribute("flow:current.force")) {
2626
- el.setAttribute("data-current", "");
2677
+ _mark(el, true);
2627
2678
  return;
2628
2679
  }
2629
2680
  const exact = el.hasAttribute("flow:current.exact");
2630
- if (_linkActive(el.getAttribute("href") ?? "", path, exact))
2631
- el.setAttribute("data-current", "");
2632
- else el.removeAttribute("data-current");
2681
+ _mark(el, _linkActive(el.getAttribute("href") ?? "", path, exact));
2633
2682
  });
2634
2683
 
2635
2684
  document.querySelectorAll("a[flow\\:navigate][flow\\:current]").forEach((el) => {
@@ -2893,6 +2942,7 @@ async function _navigateTo(href: string, options: NavigateOptions = {}): Promise
2893
2942
  currentRoot.replaceWith(incomingClone);
2894
2943
  _cleanupDisconnected();
2895
2944
  _scanComponents();
2945
+ _ensureConnected();
2896
2946
  _processHead(doc); // re-apply head from the incoming page (shell stylesheet + page meta)
2897
2947
  // Inside the swap, not after it: the document only has its new height (and
2898
2948
  // its fragment targets) once the root is in place. Inside the View
@@ -4143,7 +4193,7 @@ export function initBridge(): void {
4143
4193
  _checkBootError(); // dev-only: an initial-render throw is embedded on the page → show the overlay
4144
4194
  _scanComponents();
4145
4195
  _processHead(); // hoist any SSR <Head> content into document.head
4146
- _connect();
4196
+ _ensureConnected();
4147
4197
  _setupEventDelegation();
4148
4198
  _setupNavigationLinks();
4149
4199
  _setupModalEscape();
package/src/components.ts CHANGED
@@ -250,7 +250,10 @@ export function Modal(props: ModalProps): HtmlNode {
250
250
  if (name) {
251
251
  overlay["flow:show"] = name;
252
252
  overlay["data-flow-modal"] = name;
253
- overlay["x-trap"] = `$flow.${name}`; // trap focus while open (@alpinejs/focus)
253
+ // `.noscroll` locks the body while the dialog is open. Without it focus is
254
+ // trapped correctly and the page behind still scrolls under a finger or a
255
+ // wheel, which on a phone reads as the dialog having broken the page.
256
+ overlay["x-trap.noscroll"] = `$flow.${name}`; // focus trap + scroll lock (@alpinejs/focus)
254
257
  if (title) overlay["aria-labelledby"] = titleId;
255
258
  }
256
259
  if (!open) overlay["style"] = { display: "none" };
@@ -1482,7 +1485,7 @@ export function Drawer(props: DrawerProps): HtmlNode {
1482
1485
  };
1483
1486
  if (name) {
1484
1487
  panelOut["data-flow-modal"] = name; // Escape-to-close (shared bridge handler)
1485
- panelOut["x-trap"] = shownExpr; // trap focus while open
1488
+ panelOut["x-trap.noscroll"] = shownExpr; // focus trap + scroll lock, as Modal
1486
1489
  if (title) panelOut["aria-labelledby"] = titleId;
1487
1490
  }
1488
1491
 
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>`;