@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 +41 -0
- package/api-surface.md +4 -6
- package/package.json +6 -6
- package/src/Component.ts +123 -9
- package/src/client/bridge.ts +55 -5
- package/src/components.ts +5 -2
- package/src/registry.ts +6 -0
- package/src/router.ts +20 -7
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.
|
|
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
|
}
|
|
@@ -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
|
|
2677
|
+
_mark(el, true);
|
|
2627
2678
|
return;
|
|
2628
2679
|
}
|
|
2629
2680
|
const exact = el.hasAttribute("flow:current.exact");
|
|
2630
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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>`;
|