@zerotal/flow 1.7.5 → 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 +29 -0
- package/api-surface.md +4 -0
- package/package.json +6 -6
- package/src/Component.ts +123 -9
- package/src/client/bridge.ts +36 -1
- package/src/registry.ts +6 -0
- package/src/router.ts +20 -7
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.
|
|
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.
|
|
51
|
-
"@zerotal/core": "1.
|
|
52
|
-
"@zerotal/orm": "1.
|
|
53
|
-
"@zerotal/validator": "1.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
/**
|
package/src/client/bridge.ts
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
180
|
-
|
|
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:
|
|
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
|
-
|
|
260
|
+
${snapshotJson ? `<script type="application/json" id="flow-state-${compId}">${snapshotJson}</script>` : ""}
|
|
248
261
|
${runtimeTag}
|
|
249
262
|
</body>
|
|
250
263
|
</html>`;
|