@zerotal/arch 1.7.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.
Files changed (160) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/LICENSE +21 -0
  3. package/README.md +67 -0
  4. package/api-surface.md +283 -0
  5. package/docs/about.md +676 -0
  6. package/docs/admin/actions.md +183 -0
  7. package/docs/admin/auth.md +89 -0
  8. package/docs/admin/dashboard.md +155 -0
  9. package/docs/admin/extending-ui.md +229 -0
  10. package/docs/admin/extending.md +205 -0
  11. package/docs/admin/forms.md +155 -0
  12. package/docs/admin/index.md +144 -0
  13. package/docs/admin/operations.md +224 -0
  14. package/docs/admin/references.md +174 -0
  15. package/docs/admin/resources.md +132 -0
  16. package/docs/admin/structure.md +158 -0
  17. package/docs/admin/tables.md +185 -0
  18. package/docs/admin/testing.md +138 -0
  19. package/docs/ai.md +373 -0
  20. package/docs/application.md +435 -0
  21. package/docs/arch.md +263 -0
  22. package/docs/assets.md +755 -0
  23. package/docs/audit.md +367 -0
  24. package/docs/authentication.md +1000 -0
  25. package/docs/authorization.md +529 -0
  26. package/docs/broadcasting/channels.md +170 -0
  27. package/docs/broadcasting/client.md +108 -0
  28. package/docs/broadcasting/events.md +190 -0
  29. package/docs/broadcasting/index.md +163 -0
  30. package/docs/broadcasting/references.md +84 -0
  31. package/docs/broadcasting/testing.md +132 -0
  32. package/docs/cache.md +387 -0
  33. package/docs/carbon.md +830 -0
  34. package/docs/changelog.md +465 -0
  35. package/docs/client/auth.md +113 -0
  36. package/docs/client/errors.md +139 -0
  37. package/docs/client/files.md +118 -0
  38. package/docs/client/index.md +128 -0
  39. package/docs/client/references.md +58 -0
  40. package/docs/client/requests.md +131 -0
  41. package/docs/client/resilience.md +141 -0
  42. package/docs/client/testing.md +146 -0
  43. package/docs/commands.md +450 -0
  44. package/docs/components.md +2427 -0
  45. package/docs/config-system.md +258 -0
  46. package/docs/container.md +596 -0
  47. package/docs/context.md +579 -0
  48. package/docs/contributing.md +164 -0
  49. package/docs/controllers.md +354 -0
  50. package/docs/conventions.md +466 -0
  51. package/docs/cookies.md +101 -0
  52. package/docs/csrf.md +206 -0
  53. package/docs/database.md +476 -0
  54. package/docs/deployment.md +434 -0
  55. package/docs/devtools.md +1013 -0
  56. package/docs/email-verification.md +320 -0
  57. package/docs/encryption.md +380 -0
  58. package/docs/errors.md +317 -0
  59. package/docs/events.md +420 -0
  60. package/docs/flow/components.md +846 -0
  61. package/docs/flow/decorators.md +458 -0
  62. package/docs/flow/events.md +420 -0
  63. package/docs/flow/forms.md +551 -0
  64. package/docs/flow/index.md +568 -0
  65. package/docs/flow/layouts.md +793 -0
  66. package/docs/flow/lifecycle.md +336 -0
  67. package/docs/flow/pagination.md +402 -0
  68. package/docs/flow/performance.md +262 -0
  69. package/docs/flow/references.md +317 -0
  70. package/docs/flow/routing.md +550 -0
  71. package/docs/flow/testing.md +447 -0
  72. package/docs/getting-started.md +286 -0
  73. package/docs/health.md +269 -0
  74. package/docs/helpers.md +423 -0
  75. package/docs/i18n.md +414 -0
  76. package/docs/index.md +65 -0
  77. package/docs/inertia/build.md +95 -0
  78. package/docs/inertia/devtools.md +197 -0
  79. package/docs/inertia/index.md +267 -0
  80. package/docs/inertia/middleware.md +81 -0
  81. package/docs/inertia/props.md +507 -0
  82. package/docs/inertia/references.md +144 -0
  83. package/docs/inertia/rendering.md +189 -0
  84. package/docs/inertia/ssr.md +102 -0
  85. package/docs/inspirations.md +257 -0
  86. package/docs/lifecycle.md +331 -0
  87. package/docs/lock.md +454 -0
  88. package/docs/logger.md +517 -0
  89. package/docs/media.md +582 -0
  90. package/docs/middleware.md +509 -0
  91. package/docs/migrations.md +529 -0
  92. package/docs/monitor.md +377 -0
  93. package/docs/notifications.md +848 -0
  94. package/docs/orm/casts.md +365 -0
  95. package/docs/orm/factories.md +231 -0
  96. package/docs/orm/index.md +663 -0
  97. package/docs/orm/lifecycle.md +421 -0
  98. package/docs/orm/queries.md +692 -0
  99. package/docs/orm/relationships.md +440 -0
  100. package/docs/orm/serialization.md +251 -0
  101. package/docs/package-development.md +536 -0
  102. package/docs/pagination.md +340 -0
  103. package/docs/password-reset.md +335 -0
  104. package/docs/providers.md +447 -0
  105. package/docs/query-builder.md +465 -0
  106. package/docs/queue.md +549 -0
  107. package/docs/rate-limiting.md +253 -0
  108. package/docs/responses.md +336 -0
  109. package/docs/roles-and-2fa.md +418 -0
  110. package/docs/routing.md +1008 -0
  111. package/docs/scaffolding.md +335 -0
  112. package/docs/scheduler.md +533 -0
  113. package/docs/seeding.md +229 -0
  114. package/docs/session.md +451 -0
  115. package/docs/social.md +524 -0
  116. package/docs/storage.md +622 -0
  117. package/docs/structure.md +230 -0
  118. package/docs/support-policy.md +114 -0
  119. package/docs/telemetry.md +505 -0
  120. package/docs/tenancy.md +608 -0
  121. package/docs/testing/browser.md +153 -0
  122. package/docs/testing/console.md +130 -0
  123. package/docs/testing/database.md +238 -0
  124. package/docs/testing/flow-browser.md +216 -0
  125. package/docs/testing/http.md +441 -0
  126. package/docs/testing/index.md +240 -0
  127. package/docs/testing/mocking.md +410 -0
  128. package/docs/upgrade.md +192 -0
  129. package/docs/validator.md +549 -0
  130. package/docs/view.md +453 -0
  131. package/package.json +60 -0
  132. package/src/bin/mcp.ts +73 -0
  133. package/src/config.ts +63 -0
  134. package/src/errors.ts +27 -0
  135. package/src/index.ts +56 -0
  136. package/src/install/ArchInstallCommand.ts +189 -0
  137. package/src/install/detect.ts +98 -0
  138. package/src/install/guidelines.ts +290 -0
  139. package/src/install/markers.ts +77 -0
  140. package/src/install/mcpConfig.ts +81 -0
  141. package/src/mcp/index.ts +27 -0
  142. package/src/mcp/jsonrpc.ts +133 -0
  143. package/src/mcp/server.ts +307 -0
  144. package/src/mcp/stdio.ts +111 -0
  145. package/src/mcp/types.ts +146 -0
  146. package/src/probe/ArchProbeCommand.ts +48 -0
  147. package/src/probe/sentinel.ts +13 -0
  148. package/src/probe/topics.ts +291 -0
  149. package/src/provider/ArchProvider.ts +101 -0
  150. package/src/tools/_probe.ts +164 -0
  151. package/src/tools/apiSurface.ts +241 -0
  152. package/src/tools/appInfo.ts +88 -0
  153. package/src/tools/baselines.ts +210 -0
  154. package/src/tools/context.ts +37 -0
  155. package/src/tools/doctor.ts +93 -0
  156. package/src/tools/index.ts +50 -0
  157. package/src/tools/logs.ts +265 -0
  158. package/src/tools/routes.ts +133 -0
  159. package/src/tools/schema.ts +119 -0
  160. package/src/tools/searchDocs.ts +345 -0
@@ -0,0 +1,793 @@
1
+ ---
2
+ title: Flow Layouts & Composition
3
+ description: Wrap pages in layouts, compose behaviour with mixins, nest components, and pass markup between them.
4
+ ---
5
+
6
+ # Layouts & Composition
7
+
8
+ Wrap pages in layouts, compose reusable behaviour with mixins, nest components as islands, stream slow content progressively, attach middleware, and test it all.
9
+
10
+ ## Layouts
11
+
12
+ A `Layout` wraps the page content with persistent shell UI — the nav bar, sidebar, footer. The layout is never re-rendered on WebSocket updates; only the page content swaps. `navigate` links update the page content without a full reload while keeping the layout mounted.
13
+
14
+ ```tsx
15
+ import { Layout } from "@zerotal/flow";
16
+ import type { HtmlNode } from "@zerotal/flow";
17
+ import { Link, Flash } from "@zerotal/flow";
18
+
19
+ export class AppLayout extends Layout {
20
+ // Injected into <head> on every initial render
21
+ static override head = `
22
+ <link rel="stylesheet" href="/app.css">
23
+ <meta name="viewport" content="width=device-width, initial-scale=1">
24
+ `;
25
+
26
+ override render(slot: HtmlNode) {
27
+ return (
28
+ <div class="min-h-screen bg-gray-50">
29
+ <nav class="flex items-center px-6 py-4 border-b bg-white">
30
+ <Link href="/dashboard" navigate class="font-semibold text-lg">
31
+ My App
32
+ </Link>
33
+ <div class="ml-auto flex gap-4">
34
+ <Link href="/posts" navigate class="data-[current]:font-bold">
35
+ Posts
36
+ </Link>
37
+ <Link href="/settings" navigate class="data-[current]:font-bold">
38
+ Settings
39
+ </Link>
40
+ </div>
41
+ </nav>
42
+ <main class="p-8">{slot}</main>
43
+ <Flash position="bottom-right" />
44
+ </div>
45
+ );
46
+ }
47
+ }
48
+ ```
49
+
50
+ Attach the layout to a page:
51
+
52
+ ```tsx
53
+ export class DashboardPage extends Component {
54
+ static layout = AppLayout;
55
+
56
+ override async render() {
57
+ return (
58
+ <div>
59
+ <h1>Dashboard</h1>
60
+ </div>
61
+ );
62
+ }
63
+ }
64
+ ```
65
+
66
+ The layout's `render(slot)` receives the page's HTML as `slot`. Multiple pages can share a layout; each keeps its own state and snapshot.
67
+
68
+ ### The `layout(page)` hook — layouts as plain JSX
69
+
70
+ `static layout = SomeLayout` is the class form. The JSX-native alternative — and the same convention the framework's React/Inertia pages use — is to override the `layout(page)` method and wrap the rendered page in **any JSX** you like. There is no separate `Layout` base class and no named-slot mechanism: a layout is just a component you wrap the page in, and its regions are ordinary **props**.
71
+
72
+ ```tsx
73
+ import { Component } from "@zerotal/flow";
74
+ import type { HtmlNode } from "@zerotal/flow";
75
+ import { AppLayout } from "#app/layouts/AppLayout.tsx";
76
+
77
+ export class DashboardPage extends Component {
78
+ static title = "Dashboard";
79
+
80
+ // `page` is the rendered <div data-flow-root>…</div>. Wrap it however you like;
81
+ // named regions like `title` / `actions` are just props on your layout component.
82
+ override layout(page: HtmlNode) {
83
+ return (
84
+ <AppLayout
85
+ title={DashboardPage.title}
86
+ actions={<button onClick={this.refresh}>Refresh</button>}
87
+ >
88
+ {page}
89
+ </AppLayout>
90
+ );
91
+ }
92
+
93
+ override async render() {
94
+ return (
95
+ <div>
96
+ <h1>Dashboard</h1>
97
+ </div>
98
+ );
99
+ }
100
+ }
101
+ ```
102
+
103
+ `AppLayout` is an ordinary function component — nothing framework-specific:
104
+
105
+ ```tsx
106
+ export function AppLayout(props: { title?: string; actions?: HtmlNode; children?: unknown }) {
107
+ return (
108
+ <div data-flow-layout="app" class="min-h-screen bg-gray-50">
109
+ <header class="flex items-center px-6 py-4 border-b">
110
+ <span class="font-semibold">{props.title}</span>
111
+ <div class="ml-auto">{props.actions}</div>
112
+ </header>
113
+ <main class="p-8">{props.children}</main>
114
+ </div>
115
+ );
116
+ }
117
+ ```
118
+
119
+ Two things to know:
120
+
121
+ - **Give the shell root a stable `data-flow-layout="app"`.** That marker is what lets `navigate` swap only the page root and keep the shell mounted across visits. If you omit it, Flow derives one from the wrapper source — which matches for a plain `(page) => <AppLayout>{page}</AppLayout>`, but not when the wrapper passes page-specific props — so declaring it on the layout component is the reliable choice.
122
+ - **The shell lives outside the reactive root.** Like `static layout`, the shell renders once and is never re-rendered or re-sent on WebSocket actions. So layout regions are for **display** (titles, breadcrumbs, status); an `onClick={this.save}` placed in the shell has no owning component root above it — keep interactive controls in the page body.
123
+
124
+ `static layout` and the `layout(page)` hook are interchangeable; when a page has both, the `layout(page)` method wins. Global stylesheets/fonts for a JSX-native layout go through a `<Head>` inside the layout component (or the page's `static head`), rather than `static head` on a `Layout` class.
125
+
126
+ ### Layout head
127
+
128
+ `static head` injects content into the `<head>` element on the initial render. For per-page head content (title, meta), use `<Head>` inside the page's `render()` — it's hoisted into `<head>` on load and on every `navigate` visit:
129
+
130
+ ```tsx
131
+ import { Head } from "@zerotal/flow";
132
+
133
+ override async render() {
134
+ return (
135
+ <div>
136
+ <Head>
137
+ <title>{this.post.title} — My App</title>
138
+ <meta name="description" content={this.post.excerpt} />
139
+ </Head>
140
+ <h1>{this.post.title}</h1>
141
+ </div>
142
+ );
143
+ }
144
+ ```
145
+
146
+ ## Sections
147
+
148
+ A layout owns regions a page cannot reach. When a page needs to put something _there_ — a toolbar
149
+ button, a breadcrumb trail, a heading — the alternatives are threading it through every component in
150
+ between as props, or the layout knowing about every page that might contribute. Sections invert
151
+ that: the component that owns the content declares it, and the layout declares a hole.
152
+
153
+ ```tsx
154
+ // In the layout — declare the hole
155
+ import { SectionOutlet } from "@zerotal/flow";
156
+
157
+ <header class="flex items-center gap-2">
158
+ <h1>Admin</h1>
159
+ <SectionOutlet name="toolbar" />
160
+ </header>;
161
+ ```
162
+
163
+ ```tsx
164
+ // In any page — fill it
165
+ import { SectionContent } from "@zerotal/flow";
166
+
167
+ <SectionContent name="toolbar">
168
+ <button onClick={this.publish}>Publish</button>
169
+ </SectionContent>;
170
+ ```
171
+
172
+ `<SectionContent>` renders nothing where it appears. Children of `<SectionOutlet>` are the default,
173
+ used when no page published anything:
174
+
175
+ ```tsx
176
+ <SectionOutlet name="toolbar">
177
+ <span class="text-sm text-gray-500">No actions</span>
178
+ </SectionOutlet>
179
+ ```
180
+
181
+ Two components may publish to the same name; their content accumulates in render order rather than
182
+ one replacing the other.
183
+
184
+ **Order does not matter.** An outlet reserves its place and is filled after the page _and_ the
185
+ layout have rendered — which is what makes the usual arrangement work at all, since the layout wraps
186
+ a page that has already rendered.
187
+
188
+ > **Sections resolve once per document render.** A WebSocket patch re-renders a component, not the
189
+ > layout, so content published during one does not reach an outlet outside the component being
190
+ > patched. Put values that change on interaction in the component that renders them, and use
191
+ > sections for content that is settled by the time the page paints.
192
+
193
+ ## Composing behaviour with mixins
194
+
195
+ A layout wraps a page's _markup_. A mixin composes a page's _behaviour_ — page state, actions,
196
+ lifecycle — so a feature lives in one reusable place instead of being copied into every page that
197
+ needs it. Compose them with the `Component.using(...)` static:
198
+
199
+ ```tsx
200
+ import { Component, Pagination, FileUploads } from "@zerotal/flow";
201
+
202
+ export class PostsPage extends Component.using(Pagination, FileUploads) {
203
+ override async render() {
204
+ return <div data-flow-root>Page {this.page}</div>;
205
+ }
206
+ }
207
+ ```
208
+
209
+ Mixins fold left to right, and everything flows through to the final page: `Component`'s own
210
+ surface (`flash()`, `redirect()`, `validate()`, the client magics), plus every mixin's `@expose` /
211
+ `@locked` members. Mixin props register on the mixin's prototype, which sits in the page's
212
+ prototype chain, so the snapshot, reactivity, client writes and `@url` sync all treat them exactly
213
+ like props declared on the page itself.
214
+
215
+ Flow ships two mixins — [`Pagination`](/docs/flow/pagination) and
216
+ [`FileUploads`](/docs/flow/forms) — and you write your own the same way.
217
+
218
+ ### Writing a mixin
219
+
220
+ A mixin is a function taking a base class and returning a class that extends it. Bind the base to
221
+ `Constructor<Component>` to require a Component lineage, and return an `abstract class` so the
222
+ mixin does not have to implement `render()` — the final page supplies that:
223
+
224
+ ```tsx
225
+ // app/flow/mixins/sorting.ts
226
+ import { Component, expose, url, type Constructor } from "@zerotal/flow";
227
+
228
+ export function Sorting<T extends Constructor<Component>>(Base: T) {
229
+ abstract class WithSorting extends Base {
230
+ @url sortBy = "id";
231
+ @url sortDir: "asc" | "desc" = "asc";
232
+
233
+ @expose toggleSort(column: string): void {
234
+ if (this.sortBy === column) {
235
+ this.sortDir = this.sortDir === "asc" ? "desc" : "asc";
236
+ } else {
237
+ this.sortBy = column;
238
+ this.sortDir = "asc";
239
+ }
240
+ }
241
+ }
242
+ return WithSorting;
243
+ }
244
+ ```
245
+
246
+ ```tsx
247
+ export class UsersPage extends Component.using(Sorting, Pagination) {}
248
+ ```
249
+
250
+ ### Composing onto a shared base
251
+
252
+ `using` composes onto whatever class you call it on, not onto `Component` specifically. That lets
253
+ an app-level base carry its own state and actions and still take mixins, without being flattened
254
+ out of the prototype chain:
255
+
256
+ ```tsx
257
+ abstract class AdminPage extends Component {
258
+ @expose breadcrumb = "admin";
259
+
260
+ @expose async guard() {
261
+ /* shared authorization for every admin page */
262
+ }
263
+ }
264
+
265
+ export class DashboardPage extends AdminPage.using(Pagination) {
266
+ override async render() {
267
+ return (
268
+ <div data-flow-root>
269
+ {this.breadcrumb} — page {this.page}
270
+ </div>
271
+ );
272
+ }
273
+ }
274
+ ```
275
+
276
+ `DashboardPage` is still an `AdminPage`, so the base's `@expose` members and the mixin's are both
277
+ live on it.
278
+
279
+ ### Chaining
280
+
281
+ The composed class carries `using` itself, so composition can be built up in stages — useful when
282
+ a shared base is defined in one file and extended in another:
283
+
284
+ ```tsx
285
+ const AdminBase = Component.using(Pagination).using(Sorting);
286
+ export class ReportsPage extends AdminBase.using(FileUploads) {}
287
+ ```
288
+
289
+ > **Note** — a page composed with `using(...)` renders through the runtime path rather than the
290
+ > ahead-of-time compiler, which only statically sees a page's own `extends Component` plus its
291
+ > locally declared members. This is the same fallback complex pages already use; behaviour is
292
+ > identical, you just do not get the compile step for that page.
293
+
294
+ ## Nested components
295
+
296
+ Embed other `Component` subclasses as child components. Each child has its own isolated state, its own snapshot, and its own WebSocket update cycle. A parent re-render does not re-render existing children — their DOM and state are preserved (island architecture).
297
+
298
+ ```tsx
299
+ import { StatsWidget } from "./StatsWidget.tsx";
300
+ import { ActivityFeed } from "./ActivityFeed.tsx";
301
+
302
+ export class DashboardPage extends Component {
303
+ static layout = AppLayout;
304
+
305
+ override async render() {
306
+ return (
307
+ <div class="grid grid-cols-3 gap-6">
308
+ <h1 class="col-span-3">Dashboard</h1>
309
+
310
+ {/* Embed child components */}
311
+ <StatsWidget />
312
+ <ActivityFeed />
313
+
314
+ {/* Multiple instances of the same class — use key to distinguish */}
315
+ <CounterWidget key="counter-a" step={1} label="Likes" />
316
+ <CounterWidget key="counter-b" step={5} label="Views" />
317
+ </div>
318
+ );
319
+ }
320
+ }
321
+ ```
322
+
323
+ ### Passing props
324
+
325
+ Each prop the parent passes is assigned onto the same-named field before any lifecycle hook runs — the field's initialiser is the default:
326
+
327
+ ```tsx
328
+ export class CounterWidget extends Component {
329
+ @locked step: number = 1;
330
+ @locked label: string = "Count";
331
+
332
+ @expose count: number = 0;
333
+
334
+ @expose increment(): void {
335
+ this.count += this.step;
336
+ }
337
+
338
+ override async render() {
339
+ return (
340
+ <div class="card">
341
+ <p class="text-sm text-gray-500">{this.label}</p>
342
+ <p class="text-3xl font-bold">{this.count}</p>
343
+ <button onClick={this.increment}>+{this.step}</button>
344
+ </div>
345
+ );
346
+ }
347
+ }
348
+ ```
349
+
350
+ Props that need to survive WebSocket round-trips must be `@locked` so they are included in the snapshot. A `@locked` prop is set once at mount and stays fixed for the child's lifetime.
351
+
352
+ ### Slots
353
+
354
+ Where props pass **data** into a child, slots pass **markup**. A child component's plain children become its **default slot**; a `slots={{ … }}` prop supplies **named slots**. Inside the child, place each with `this.slot(name)` (or `this.slot()` for the default), and branch on `this.hasSlot(name)` to drop an optional wrapper entirely. This is the pattern for reusable shells — cards, modals, panels, page headers — where the container is fixed but the contents vary per use.
355
+
356
+ ```tsx
357
+ // The reusable shell — header and footer are optional.
358
+ export class Card extends Component {
359
+ override async render() {
360
+ return (
361
+ <div class="rounded-xl border bg-white shadow-sm">
362
+ {this.hasSlot("header") && (
363
+ <header class="border-b px-5 py-3 font-semibold">{this.slot("header")}</header>
364
+ )}
365
+ <div class="px-5 py-4">{this.slot()}</div>
366
+ {this.hasSlot("footer") && (
367
+ <footer class="border-t px-5 py-3 text-right">{this.slot("footer")}</footer>
368
+ )}
369
+ </div>
370
+ );
371
+ }
372
+ }
373
+
374
+ // A page using it — default children fill the body; named slots fill header/footer.
375
+ export class BillingPage extends Component {
376
+ override async render() {
377
+ return (
378
+ <Card
379
+ slots={{
380
+ header: <h2>Payment method</h2>,
381
+ footer: <button onClick={this.save}>Save</button>,
382
+ }}
383
+ >
384
+ <p>Your card ending in 4242 is active.</p>
385
+ </Card>
386
+ );
387
+ }
388
+ }
389
+ ```
390
+
391
+ Slot content is rendered in the **parent's** scope and carried in the child's snapshot (signed, so it can't be forged from the client). Because it lives in the snapshot, it survives the child's own round-trips — a child action re-renders with the same header/body/footer without the parent running again.
392
+
393
+ Two consequences worth knowing:
394
+
395
+ - **Slots are set at mount, not reactive.** They reflect the parent's state at the moment the child mounts. If the parent later re-renders, the existing child island is preserved (its DOM and snapshot are kept), so the slot HTML does not change underneath it. For a value that must track the parent live, pass it as a `@reactive` prop instead of as slot markup.
396
+ - **Prefer plain markup in slots.** Interactive `onClick={this.method}` handlers inside a slot bind to the _parent's_ actions (the slot was rendered in the parent's scope), which is usually what you want for a footer button. Nesting another _stateful child component_ inside a slot is not supported — embed it in the child's own `render()` instead.
397
+
398
+ ### `key` in a list
399
+
400
+ Give every child rendered inside a `.map()` a `key` tied to the row's own identity:
401
+
402
+ ```tsx
403
+ {
404
+ this.settings.map((s) => <SettingRow key={`setting-${s.id}`} settingKey={s.key} />);
405
+ }
406
+ ```
407
+
408
+ This is a correctness requirement, not an optimisation, and it is worth understanding why.
409
+
410
+ A child's `data-flow-id` is how the client morph pairs the incoming markup with the island already on the page — and a parent re-render deliberately emits an already-mounted child as an **empty stub**, on the understanding that the pairing will preserve the child's live DOM. When two renders disagree about which child owns an id, that stub is what lands in the page.
411
+
412
+ Without a `key`, the id is derived from the child's seed props (`@reactive` and `@modelable` props are excluded, because those exist to change without remounting). That is stable enough for the common case: remove an item from the middle of a list and the rows around it keep their islands. But two siblings whose props are identical are, as far as the framework can see, the same child — they share an id, and therefore share DOM and state. Flow logs a warning the first time it sees that, in development only.
413
+
414
+ None of this is visible from the server. SSR, snapshot assertions and `FlowTest.mount(...).call(...)` all render the full child every time, because they never take the already-mounted branch — only a real browser applying a real WebSocket patch does. So the guard is the `key`, not the test suite.
415
+
416
+ Keys are sanitised to `[a-zA-Z0-9_-]`, so dots are stripped and `a.b` collides with `ab`.
417
+
418
+ ### Lazy, deferred, and streamed loading
419
+
420
+ ```tsx
421
+ // Defer mount until the placeholder enters the viewport (intersection observer)
422
+ <HeavyChart key="chart" lazy />
423
+
424
+ // Mount immediately after page paint (non-blocking)
425
+ <Sidebar defer />
426
+
427
+ // Render on the SAME response — placeholder first, real markup streamed after
428
+ <SalesReport stream />
429
+ ```
430
+
431
+ `lazy` and `defer` both mount over the socket on a **second round trip**, which is what you want for
432
+ content that may never be needed: a widget below the fold, a tab nobody opens.
433
+
434
+ `stream` is for content that is definitely needed and merely slow. The shell reaches the browser
435
+ without waiting for it, and the child's markup arrives as a trailing chunk of the same response —
436
+ no second request, no socket, and no client runtime needed (the swap happens during parse, so it
437
+ works before and without Alpine). See [Streaming the initial render](#streaming-the-initial-render).
438
+
439
+ Override `placeholder()` to customise the skeleton shown while a lazy component loads:
440
+
441
+ ```tsx
442
+ export class HeavyChart extends Component {
443
+ override placeholder() {
444
+ return <div class="h-64 w-full rounded-xl bg-gray-200 animate-pulse" />;
445
+ }
446
+
447
+ override async onMount() {
448
+ this.data = await Analytics.fetchChartData();
449
+ }
450
+
451
+ // …
452
+ }
453
+ ```
454
+
455
+ ## Reactive props
456
+
457
+ A `@locked` prop is frozen after mount. Mark a prop `@reactive` instead and the parent re-pushes its value whenever it changes, re-rendering the child — while the child keeps the rest of its own state intact:
458
+
459
+ ```tsx
460
+ export class PriceTag extends Component {
461
+ @reactive currency = "USD";
462
+ @reactive amount = 0;
463
+
464
+ @computed get formatted(): string {
465
+ return new Intl.NumberFormat("en-US", {
466
+ style: "currency",
467
+ currency: this.currency,
468
+ }).format(this.amount);
469
+ }
470
+
471
+ override async render() {
472
+ return <span class="price">{this.formatted}</span>;
473
+ }
474
+ }
475
+
476
+ // Parent — when this.currency changes, PriceTag automatically re-renders:
477
+ <PriceTag currency={this.currency} amount={this.subtotal} />;
478
+ ```
479
+
480
+ ## Two-way props
481
+
482
+ `@modelable` is a reactive prop that also syncs **back** to the parent. The parent property and the child prop stay in lock-step, so you can build reusable input/control components:
483
+
484
+ ```tsx
485
+ export class StarRating extends Component {
486
+ @modelable rating: number = 0; // two-way bound to parent
487
+
488
+ @expose set(n: number): void {
489
+ this.rating = n; // updating it flows up to the parent
490
+ }
491
+
492
+ override async render() {
493
+ return (
494
+ <div class="flex gap-1">
495
+ {[1, 2, 3, 4, 5].map((n) => (
496
+ <button
497
+ key={String(n)}
498
+ onClick={() => this.set(n)}
499
+ class={n <= this.rating ? "text-yellow-400" : "text-gray-300"}
500
+ >
501
+
502
+ </button>
503
+ ))}
504
+ </div>
505
+ );
506
+ }
507
+ }
508
+
509
+ // Parent — this.productRating and StarRating.rating stay in sync both ways:
510
+ <StarRating value={this.productRating} />;
511
+ ```
512
+
513
+ ## Streaming
514
+
515
+ Flow streams in two distinct places: **during the initial page response**, so a slow child does not
516
+ hold up the shell, and **during an action**, so a long-running method can push progress before it
517
+ finishes. They solve different problems and do not interact.
518
+
519
+ ### Streaming the initial render
520
+
521
+ Mark a child `stream` and the page paints immediately with that child's placeholder; its real markup
522
+ is appended to the same response as soon as it finishes rendering:
523
+
524
+ ```tsx
525
+ override async render() {
526
+ return (
527
+ <div>
528
+ <h1>Dashboard</h1>
529
+ <Totals /> {/* fast — rendered inline */}
530
+ <SalesReport stream /> {/* slow — placeholder now, markup later */}
531
+ </div>
532
+ );
533
+ }
534
+ ```
535
+
536
+ The browser receives the document up to the placeholder, then a `<template>` carrying the finished
537
+ markup and a one-line script that swaps it in. That script runs during parse, so the content appears
538
+ without waiting for the runtime — and without a second request. Override `placeholder()` on the
539
+ child to control what shows in the meantime.
540
+
541
+ A child that fails while streaming is replaced with a notice rather than failing the response: the
542
+ shell is already on the wire by then, so there is nothing left to fail. Everything else on the page
543
+ is unaffected.
544
+
545
+ Streaming applies to the initial `GET` only. Over the socket there is no open response to append to,
546
+ so `stream` degrades to an ordinary inline child render.
547
+
548
+ > **Streaming needs an unbuffered response.** Flow sets `X-Accel-Buffering: no` for nginx. Behind a
549
+ > proxy that buffers anyway, the browser simply receives the whole document at once — the page is
550
+ > correct, just not progressive.
551
+
552
+ ### Streaming during an action
553
+
554
+ Push content to the client mid-action — before the final patch — using `this.stream()`. Useful for LLM token streaming, long-running progress updates, or any content that takes time:
555
+
556
+ ```tsx
557
+ {/* In the template: declare the stream target */}
558
+ <div stream="answer" class="prose" />
559
+ <div stream="status" class="text-sm text-gray-500" />
560
+ ```
561
+
562
+ ```typescript
563
+ @expose async generate(): Promise<void> {
564
+ this.stream("status", "Generating…");
565
+
566
+ for await (const token of llm.stream(this.prompt)) {
567
+ this.stream("answer", token); // appended progressively
568
+ }
569
+
570
+ this.stream("status", "Done.");
571
+ }
572
+
573
+ // Replace instead of append:
574
+ this.stream("output", freshContent, { replace: true });
575
+ ```
576
+
577
+ `this.stream()` is a no-op during the initial SSR render — it only works inside WebSocket action handlers.
578
+
579
+ ### `@task` — streaming, cancellable actions
580
+
581
+ `this.stream()` is the low-level primitive: it pushes raw HTML into a `flow:stream` target, but that content isn't part of the snapshot, so you must _also_ write the accumulated result to a field for the final render, and there's no built-in cancellation. `@task` handles both. Mark an async method `@task` and just **write the field** — the framework streams it:
582
+
583
+ ```tsx
584
+ import { Component, task, expose } from "@zerotal/flow";
585
+
586
+ export class Chat extends Component {
587
+ @expose answer = "";
588
+
589
+ @task async generate() {
590
+ this.answer = "";
591
+ for await (const token of llm.stream(this.prompt, { signal: this.signal })) {
592
+ if (this.cancelled) break; // cooperative cancellation
593
+ this.answer += token; // ← streams to the browser as it's written
594
+ }
595
+ }
596
+ }
597
+ ```
598
+
599
+ ```tsx
600
+ {/* Bind the streamed field REACTIVELY (text={…} → flow:text, or x-text) so each write updates
601
+ this element live off the client store — no flow:stream element, no re-render per chunk. */}
602
+ <button onClick={this.generate} loadingAttr="disabled">Generate</button>
603
+ <button onClick={() => $flow.cancel()} showOnLoading>Cancel</button>
604
+ <div text={this.answer} />
605
+ ```
606
+
607
+ What `@task` gives you over a plain `@expose async` action:
608
+
609
+ - **Incremental streaming without a re-render.** While the task runs, the framework flushes throttled **field-level** snapshot diffs — the changed fields only, no HTML — so a reactive binding of the field (`text={this.answer}`, `x-text="$flow.answer"`, a reactive `:class`/`:attr`) updates the DOM straight off the client store as `this.answer += token` runs, with no per-chunk component re-render. The field _is_ the snapshot, so the final patch re-renders once to reconcile any **static** template positions (a plain `{this.answer}` text child updates then), and everything stays consistent — no double-writing. Bind the streamed field reactively for a live token-by-token view.
610
+ - **A loading state that spans the whole run.** The triggering control stays in its loading state (`loadingAttr`, `showOnLoading`, `<Loading>`) for the task's entire duration — partial patches don't clear it; only completion does.
611
+ - **First-class cancellation.** `this.signal` is a standard `AbortSignal` (pass it to `fetch`/an SDK); `this.cancelled` is a convenience boolean. On the client, `$flow.cancel()` stops the task — it's sent out-of-band (bypassing the per-component send queue, which the running task still occupies), and the server trips the task's `AbortSignal`. Cancellation is cooperative: check `this.cancelled`/`this.signal.aborted`, or let an aborted `signal` reject the async work you're awaiting.
612
+
613
+ This is the primitive for AI answers, build/deploy logs, and progress feeds — the streaming shape that usually needs a second endpoint and a client state library, here in one server method. Outside a running task, `this.signal` is an inert signal that never aborts, so `@task` code reads the same whether or not a cancellation is in flight.
614
+
615
+ ## Middleware
616
+
617
+ Middleware attached to a `Router.flow()` call runs on the initial HTTP `GET` **and** on every WebSocket update for that page. This keeps auth gates active across the entire session, not just at page load:
618
+
619
+ ```typescript
620
+ import { Router } from "zerotal";
621
+ import { RequireAuthMiddleware } from "#app/middleware/RequireAuth.ts";
622
+ import { RequireAdminMiddleware } from "#app/middleware/RequireAdmin.ts";
623
+ import { AdminDashboard } from "#app/flow/AdminDashboard.tsx";
624
+ import { PublicPosts } from "#app/flow/PublicPosts.tsx";
625
+
626
+ // No middleware — anyone can view
627
+ Router.flow("/posts", PublicPosts);
628
+
629
+ // Requires auth on every request (initial GET + WS)
630
+ Router.flow("/dashboard", DashboardPage, [RequireAuthMiddleware]);
631
+
632
+ // Requires auth AND admin role
633
+ Router.flow("/admin", AdminDashboard, [RequireAuthMiddleware, RequireAdminMiddleware]);
634
+ ```
635
+
636
+ ## Testing composed components
637
+
638
+ See [Testing](/docs/flow/testing) for the full guide — mounting, calling actions, seeding state, all assertions, database integration, and event testing.
639
+
640
+ `FlowTest` runs the full server-side pipeline in-process — no WebSocket connection needed. It's available from `@zerotal/flow/testing`.
641
+
642
+ ### Mounting a composed component
643
+
644
+ ```typescript
645
+ import { FlowTest } from "@zerotal/flow/testing";
646
+ import { CounterPage } from "../app/flow/CounterPage.tsx";
647
+
648
+ // Mount drives: onBoot → onMount → render → onDehydrate
649
+ const t = await FlowTest.mount(CounterPage);
650
+
651
+ // Inspect initial state
652
+ expect(t.page().count).toBe(0);
653
+ t.assertSee("Count: 0");
654
+ t.assertDontSee("Count: 1");
655
+ ```
656
+
657
+ Mount with initial props (seed state before `onMount`):
658
+
659
+ ```typescript
660
+ const t = await FlowTest.mount(PostsPage, { page: 2, search: "TypeScript" });
661
+ expect(t.page().search).toBe("TypeScript");
662
+ ```
663
+
664
+ ### Calling composed actions
665
+
666
+ ```typescript
667
+ // call() drives: onBoot → onHydrate → action() → onUpdate → render → onDehydrate
668
+ await t.call("increment");
669
+ expect(t.page().count).toBe(1);
670
+ t.assertSee("Count: 1");
671
+
672
+ // Call with arguments
673
+ await t.call("setCount", 42);
674
+ expect(t.page().count).toBe(42);
675
+ ```
676
+
677
+ ### Updating composed state
678
+
679
+ Two ways to change a property between calls:
680
+
681
+ ```typescript
682
+ // set() — direct assignment, no hooks fire
683
+ await t.set("draft", "Hello world");
684
+
685
+ // update() — simulates a client input, fires onUpdating/onUpdated hooks
686
+ await t.update("username", "alice");
687
+ ```
688
+
689
+ Use `set()` to seed state for a specific scenario. Use `update()` to test that your `onUpdating`/`onUpdated` hooks work correctly.
690
+
691
+ ### Assertions
692
+
693
+ **HTML assertions:**
694
+
695
+ ```typescript
696
+ t.assertSee("Published post"); // HTML contains this string
697
+ t.assertDontSee("Error"); // HTML does NOT contain this string
698
+ ```
699
+
700
+ **Validation assertions:**
701
+
702
+ ```typescript
703
+ await t.call("save");
704
+ t.assertHasErrors("email"); // field has at least one error
705
+ t.assertHasErrors("email", "required"); // error message contains "required"
706
+ t.assertNoErrors(); // no errors at all
707
+ ```
708
+
709
+ **Redirect assertions:**
710
+
711
+ ```typescript
712
+ t.assertRedirectedTo("/dashboard");
713
+ t.assertNotRedirected();
714
+ ```
715
+
716
+ **Flash assertions:**
717
+
718
+ ```typescript
719
+ t.assertFlashed("success", "Saved."); // level + message substring
720
+ t.assertFlashed("error"); // just check the level
721
+ t.assertFlashed(undefined, "Something went"); // just check the message substring
722
+ ```
723
+
724
+ **Event assertions:**
725
+
726
+ ```typescript
727
+ t.assertDispatched("post-created");
728
+ ```
729
+
730
+ ### Composed accessors
731
+
732
+ ```typescript
733
+ t.page(); // the Component instance — inspect properties directly
734
+ t.html(); // the rendered HTML string
735
+ t.errors(); // current validation error bag: Record<string, string[]>
736
+ t.effects(); // effects from the last action (flashes, redirects, events, downloads)
737
+ t.snapshot(); // the serialised snapshot
738
+ ```
739
+
740
+ ### Full test example
741
+
742
+ ```typescript
743
+ import { describe, test, expect, beforeEach } from "bun:test";
744
+ import { FlowTest } from "@zerotal/flow/testing";
745
+ import { LoginPage } from "#app/flow/LoginPage.tsx";
746
+ import { User } from "#app/models/User.ts";
747
+ import { withDatabase } from "#tests/helpers.ts";
748
+
749
+ describe("LoginPage", () => {
750
+ test("redirects to dashboard on valid credentials", async () => {
751
+ await User.create({ email: "alice@example.com", password: "secret123" });
752
+
753
+ const t = await FlowTest.mount(LoginPage);
754
+ await t.set("email", "alice@example.com");
755
+ await t.set("password", "secret123");
756
+ await t.call("login");
757
+
758
+ t.assertRedirectedTo("/dashboard");
759
+ t.assertNoErrors();
760
+ });
761
+
762
+ test("shows error on invalid credentials", async () => {
763
+ const t = await FlowTest.mount(LoginPage);
764
+ await t.set("email", "alice@example.com");
765
+ await t.set("password", "wrongpassword");
766
+ await t.call("login");
767
+
768
+ t.assertNotRedirected();
769
+ t.assertHasErrors("email", "credentials");
770
+ });
771
+
772
+ test("validates required fields", async () => {
773
+ const t = await FlowTest.mount(LoginPage);
774
+ await t.call("login"); // no email or password set
775
+
776
+ t.assertHasErrors("email", "required");
777
+ t.assertHasErrors("password", "required");
778
+ t.assertNotRedirected();
779
+ });
780
+
781
+ test("normalises email to lowercase via onUpdated hook", async () => {
782
+ const t = await FlowTest.mount(LoginPage);
783
+ await t.update("email", "ALICE@EXAMPLE.COM");
784
+
785
+ expect(t.page().email).toBe("alice@example.com");
786
+ });
787
+ });
788
+ ```
789
+
790
+ ## Next steps
791
+
792
+ - [Flow overview](/docs/flow) — the guide's front page and the rest of the sections.
793
+ - [Reference](/docs/flow/references) — every decorator, prop, and directive in one table.