@zerotal/flow 1.8.1 → 1.10.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,6 +11,63 @@ change.
11
11
 
12
12
  ## [Unreleased]
13
13
 
14
+ ## [1.9.0] — 2026-08-29
15
+
16
+ - **The component and flash types are documented.** Every built-in component exports the type of
17
+ its own props — `ModalProps`, `ForProps`, `VirtualizeProps` and eighteen more — and none was
18
+ named anywhere a reader would look, though wrapping a component is the usual reason to want
19
+ one. The flash API's shapes are documented alongside them: `FlashBuilder` and the eight types
20
+ around it, for a helper that composes a message rather than writing one inline.
21
+
22
+ ### Changed
23
+
24
+ - **INTERNAL: 35 exports are marked `@internal`.** The compiler's output helpers (`__esc`,
25
+ `__escAttr`, `jsLiteral`), the URL-safety internals, the wire-protocol frame types
26
+ (`PatchFrame`, `EventFrame`, `RedirectFrame`, …), the registration functions, the durable and
27
+ shared store accessors, and `FlowBrowser`'s Chrome discovery.
28
+
29
+ **Nothing is removed and nothing breaks.** The frame types are the clearest case: they
30
+ describe what crosses the socket, an app never constructs one, and naming them in the promised
31
+ surface implied a stability guarantee about a protocol that is free to change.
32
+
33
+ - **A bound field the model will not accept now says so.** `flow:model` on a column missing from
34
+ `fillable` was dropped in silence: the form submitted, nothing was written, nothing failed, and
35
+ there was no clue. The drop stays — the same path receives whatever a browser sends, so an
36
+ unknown key must not write a column and must not be able to 500 the page — but a developer's
37
+ typo produced exactly the same silence as a hostile payload. In development it now warns once
38
+ per class per field, naming the field, the current `fillable`, and both ways to fix it.
39
+
40
+ - **The client entries are held to the `@zerotal/orm` boundary.** Every orm import in this
41
+ package is `import type` — deliberately, because a root import once dragged
42
+ `await import("bun")` into a browser bundle and broke it at _resolution_ time, before
43
+ tree-shaking could drop it. One exception exists: `ModelSynth` imports a value. It is safe only
44
+ because no client entry reaches a synth, and that condition had been verified by hand, once,
45
+ with nothing holding it. A test now walks the import graph from each client entry and fails
46
+ naming the module that crossed.
47
+
48
+ - **The plain-form enhancement bundle is built on first request, not at boot.** Most apps never
49
+ serve a page that uses `<form data-enhance>`, so building it at boot spends time on an artefact
50
+ nobody asked for, in every process.
51
+
52
+ ### Added
53
+
54
+ - **`<form data-enhance>` — plain-form enhancement, no component required.** A page that is
55
+ just server-rendered HTML can now have a form that submits without the page flashing: it
56
+ posts through `fetch` and the matching form in the response replaces it in place, so a
57
+ validation error lands where the person is looking and nothing else on the page moves.
58
+
59
+ Delivered as its own dependency-free bundle at `/__flow/enhance.js`, because the whole point
60
+ is a page with no Flow component on it — such a page is never served `/__flow/runtime.js`,
61
+ and pulling Alpine and a WebSocket bridge onto it would be paying the framework's entire
62
+ client cost for a submit handler. Add it with `flowEnhanceTag()` in a non-Flow layout.
63
+
64
+ Every path degrades: a network failure re-submits natively rather than losing what was
65
+ typed, a redirect is followed and `pushState`d so the address bar agrees, and no JavaScript
66
+ at all is an ordinary form post — `data-enhance` is additive on a form that already works,
67
+ so there is no fallback path that can rot.
68
+
69
+ This completes ACTION 8, whose other two items turned out to be already shipped.
70
+
14
71
  ## [1.8.0] — 2026-08-24
15
72
 
16
73
  ### Added
package/api-surface.md CHANGED
@@ -174,9 +174,7 @@ class WebSocketDisconnected = {
174
174
  readonly url: string
175
175
  }
176
176
 
177
- const BLOCKED_URL = 'about:blank'
178
-
179
- const DEFAULT_PERSISTENT_MIDDLEWARE = (string | MiddlewareClass)[]
177
+ const FLOW_ENHANCE_PATH = '/__flow/enhance.js'
180
178
 
181
179
  const locked = (value: unknown, context: ClassFieldDecoratorContext) => void
182
180
 
@@ -186,8 +184,6 @@ const reactive = (value: unknown, context: ClassFieldDecoratorContext) => void
186
184
 
187
185
  const transient = (value: unknown, context: ClassFieldDecoratorContext) => void
188
186
 
189
- const URL_ATTRIBUTES = ReadonlySet<string>
190
-
191
187
  function Accordion = (props: AccordionProps) => HtmlNode
192
188
 
193
189
  function Alert = (props: AlertProps) => HtmlNode
@@ -230,26 +226,16 @@ function Flash = (props?: FlashProps) => HtmlNode
230
226
 
231
227
  function flow = <T>(target: T) => FlowChain<T>
232
228
 
233
- function flowActiveConnections = () => number
234
-
235
229
  function FlowConfig = (options?: Partial<FlowConfigShape>) => FlowConfigShape
236
230
 
237
- function flowConnections = () => FlowConnection[]
231
+ function flowEnhanceTag = () => string
238
232
 
239
233
  function For = <T>(props: ForProps<T>) => HtmlNode
240
234
 
241
- function getDurableStore = () => DurableStore
242
-
243
- function getSharedStore = () => SharedStore
244
-
245
235
  function Head = (props: { children?: unknown;}) => HtmlNode
246
236
 
247
237
  function InfiniteScroll = (props: InfiniteScrollProps) => HtmlNode
248
238
 
249
- function isSafeUrl = (url: string) => boolean
250
-
251
- function jsLiteral = (value: unknown) => string
252
-
253
239
  function Label = (props: LabelProps) => HtmlNode
254
240
 
255
241
  function Legend = (props: LegendProps) => HtmlNode
@@ -280,18 +266,10 @@ function presence = (channel: PresenceChannel) => (value: unknown, context: Clas
280
266
 
281
267
  function RadioGroup = (props: RadioGroupProps) => HtmlNode
282
268
 
283
- function registerComponent = (PageClass: typeof Component, path?: string) => void
284
-
285
269
  function registerFlowEvent = <K extends EventName>(name: K, guard: (payload: unknown) => payload is FlowEvents[K]) => void
286
270
 
287
- function registerForm = (FormClass: new () => Form) => void
288
-
289
- function registerSynth = (synth: Synth) => void
290
-
291
271
  function renderless = (_fn: unknown, context: ClassMethodDecoratorContext) => void
292
272
 
293
- function sanitizeUrl = (url: string) => string
294
-
295
273
  function SectionContent = (props: SectionContentProps) => HtmlNode
296
274
 
297
275
  function SectionOutlet = (props: SectionOutletProps) => HtmlNode
@@ -371,15 +349,6 @@ interface CalendarProps = {
371
349
  weekdayClass?: string
372
350
  }
373
351
 
374
- interface CallFrame = {
375
- args: unknown[]
376
- component: string
377
- method: string
378
- snapshot: Snapshot
379
- type: 'call'
380
- updates: Record<string, unknown>
381
- }
382
-
383
352
  interface CheckboxProps = {
384
353
  [key: string]: unknown
385
354
  bind: unknown
@@ -387,12 +356,6 @@ interface CheckboxProps = {
387
356
  class?: string
388
357
  }
389
358
 
390
- interface ChildMemo = {
391
- id: string
392
- key: string
393
- name: string
394
- }
395
-
396
359
  interface ComboboxOption = {
397
360
  label: string
398
361
  value: string | number
@@ -440,13 +403,6 @@ interface DisclosureProps = {
440
403
  trigger?: unknown
441
404
  }
442
405
 
443
- interface DownloadFrame = {
444
- content: string
445
- filename: string
446
- mime: string
447
- type: 'download'
448
- }
449
-
450
406
  interface DrawerProps = {
451
407
  [key: string]: unknown
452
408
  children?: unknown
@@ -469,12 +425,6 @@ interface DropdownProps = {
469
425
  trigger?: unknown
470
426
  }
471
427
 
472
- interface DurableStore = {
473
- delete: (key: string) => Promise<void> | void
474
- get: (key: string) => Promise<Snapshot | undefined> | Snapshot | undefined
475
- set: (key: string, value: Snapshot, ttlMs?: number) => Promise<void> | void
476
- }
477
-
478
428
  interface ErrorBoundaryProps = {
479
429
  [key: string]: unknown
480
430
  children?: unknown
@@ -484,15 +434,6 @@ interface ErrorBoundaryProps = {
484
434
  tag?: string
485
435
  }
486
436
 
487
- interface ErrorFrame = {
488
- action?: string
489
- component: string
490
- message: string
491
- name?: string
492
- stack?: string
493
- type: 'error'
494
- }
495
-
496
437
  interface ErrorProps = {
497
438
  [key: string]: unknown
498
439
  class?: string
@@ -505,20 +446,6 @@ interface ErrorsProps = {
505
446
  only?: string | string[]
506
447
  }
507
448
 
508
- interface ErrorsProxy = {
509
- [key: string]: ErrorField | ((...args: never[]) => unknown)
510
- add: { (field: string, message: string): void; (errors: Record<string, string | string[]>): void;}
511
- any: () => boolean
512
- clear: (field?: string) => void
513
- has: (field: string) => boolean
514
- }
515
-
516
- interface EventFrame = {
517
- data: Record<string, unknown>
518
- name: string
519
- type: 'event'
520
- }
521
-
522
449
  interface FieldProps = {
523
450
  [key: string]: unknown
524
451
  children?: unknown
@@ -583,20 +510,6 @@ interface FlashCallback = {
583
510
  method: string
584
511
  }
585
512
 
586
- interface FlashFrame = {
587
- actions?: FlashAction[]
588
- dismissible?: boolean
589
- duration?: number
590
- icon?: string | false
591
- level: 'success' | 'error' | 'warning' | 'info'
592
- message: string
593
- onClose?: FlashCallback
594
- position?: FlashPosition
595
- progressBar?: boolean
596
- title?: string
597
- type: 'flash'
598
- }
599
-
600
513
  interface FlashMessage = {
601
514
  actions?: FlashAction[]
602
515
  dismissible?: boolean
@@ -611,16 +524,16 @@ interface FlashMessage = {
611
524
  }
612
525
 
613
526
  interface FlashOptions = {
614
- actions?: FlashAction[]
615
- dismissible?: boolean
616
- duration?: number
617
- icon?: string | false
618
- level?: 'success' | 'error' | 'warning' | 'info'
619
- onClose?: FlashCallback
620
- position?: FlashPosition
621
- progressBar?: boolean
622
- title?: string
623
- type?: 'success' | 'error' | 'warning' | 'info'
527
+ actions?: FlashAction[] | undefined
528
+ dismissible?: boolean | undefined
529
+ duration?: number | undefined
530
+ icon?: string | false | undefined
531
+ level?: 'success' | 'error' | 'warning' | 'info' | undefined
532
+ onClose?: FlashCallback | undefined
533
+ position?: FlashPosition | undefined
534
+ progressBar?: boolean | undefined
535
+ title?: string | undefined
536
+ type?: 'success' | 'error' | 'warning' | 'info' | undefined
624
537
  }
625
538
 
626
539
  interface FlashProps = {
@@ -658,26 +571,6 @@ interface FlowConfigShape = {
658
571
  persistentMiddleware: (string | MiddlewareClass)[]
659
572
  }
660
573
 
661
- interface FlowConnection = {
662
- actions: number
663
- connectedAt: number
664
- id: string
665
- ip: string
666
- lastActivityAt: number
667
- user: string | null
668
- }
669
-
670
- interface FlowEffects = {
671
- downloads: { filename: string; content: string; mime: string;}[]
672
- errors: Record<string, string[]>
673
- events: { name: string; data: Record<string, unknown>; to?: string; self?: boolean;}[]
674
- flashes: FlashMessage[]
675
- redirectUrl: string | null
676
- scripts: string[]
677
- shouldRefresh: boolean
678
- title: string | null
679
- }
680
-
681
574
  interface FlowEvents = {}
682
575
 
683
576
  interface FlowStore = {}
@@ -757,16 +650,6 @@ interface ModalProps = {
757
650
  title?: unknown
758
651
  }
759
652
 
760
- interface PageClassWithMeta = {
761
- new (): Component
762
- head?: string
763
- interactive?: boolean
764
- layout?: new () => Layout
765
- models?: Record<string, typeof BaseModel>
766
- name: string
767
- title?: string | ((component: never) => string)
768
- }
769
-
770
653
  interface PagerProps = {
771
654
  [key: string]: unknown
772
655
  activeClass?: string
@@ -792,22 +675,6 @@ interface Paginator = {
792
675
  total: number
793
676
  }
794
677
 
795
- interface PatchFrame = {
796
- actionError?: boolean
797
- checksum?: string
798
- component: string
799
- dataDelta?: SnapshotData
800
- dataRemoved?: string[]
801
- errors?: Record<string, string[]>
802
- html?: string
803
- memo?: SnapshotMemo
804
- partial?: boolean
805
- scripts?: string[]
806
- snapshot?: Snapshot
807
- title?: string
808
- type: 'patch'
809
- }
810
-
811
678
  interface PopoverProps = {
812
679
  [key: string]: unknown
813
680
  buttonClass?: string
@@ -844,12 +711,6 @@ interface RedirectFlash = {
844
711
  withWarning: (message: string) => RedirectFlash
845
712
  }
846
713
 
847
- interface RedirectFrame = {
848
- sessionToken?: string
849
- type: 'redirect'
850
- url: string
851
- }
852
-
853
714
  interface SectionContentProps = {
854
715
  children?: unknown
855
716
  name: string
@@ -874,14 +735,8 @@ interface SelectProps = {
874
735
  }
875
736
 
876
737
  interface SessionOptions = {
877
- key?: string
878
- scoped?: boolean
879
- }
880
-
881
- interface SharedStore = {
882
- get: (key: string) => unknown | undefined
883
- has: (key: string) => boolean
884
- set: (key: string, value: unknown) => void
738
+ key?: string | undefined
739
+ scoped?: boolean | undefined
885
740
  }
886
741
 
887
742
  interface SkeletonProps = {
@@ -913,19 +768,6 @@ interface Snapshot = {
913
768
  memo: SnapshotMemo
914
769
  }
915
770
 
916
- interface SnapshotMemo = {
917
- children: string[]
918
- iat?: number | undefined
919
- id: string
920
- listeners?: Record<string, string> | undefined
921
- name: string
922
- path: string
923
- presence?: { prop: string; channel: string;}[] | undefined
924
- shared?: { prop: string; channel: string;}[] | undefined
925
- slots?: Record<string, string> | undefined
926
- sub?: string | undefined
927
- }
928
-
929
771
  interface SwitchProps = {
930
772
  [key: string]: unknown
931
773
  bind: unknown
@@ -933,13 +775,6 @@ interface SwitchProps = {
933
775
  class?: string
934
776
  }
935
777
 
936
- interface Synth = {
937
- dehydrate: (value: T, meta: Record<string, unknown>) => unknown
938
- hydrate: (data: unknown, meta: Record<string, unknown>) => T | Promise<T>
939
- key: string
940
- match: (value: unknown) => value is T
941
- }
942
-
943
778
  interface TabItem = {
944
779
  content: unknown
945
780
  label: unknown
@@ -1010,8 +845,8 @@ interface UploadRef = {
1010
845
  }
1011
846
 
1012
847
  interface UrlOptions = {
1013
- as?: string
1014
- history?: 'replace' | 'push'
848
+ as?: string | undefined
849
+ history?: 'replace' | 'push' | undefined
1015
850
  }
1016
851
 
1017
852
  interface VirtualizeProps = {
@@ -1048,10 +883,6 @@ type Mixin = (Base: TIn) => TOut
1048
883
 
1049
884
  type PresenceChannel = string | ((self: Record<string, any>) => string)
1050
885
 
1051
- type SharedChannel = string | ((self: Record<string, any>) => string)
1052
-
1053
- type SnapshotData = { [x: string]: SnapshotTuple;}
1054
-
1055
886
  type ValidationRules = { [x: string]: (rule: RuleBuilder) => FieldRule;}
1056
887
 
1057
888
  ## ./browser `(./src/browser/index.ts)`
@@ -1076,18 +907,9 @@ class FlowBrowser = {
1076
907
  waitUntil: (expression: string, label: string, timeoutMs?: number) => Promise<void>
1077
908
  }
1078
909
 
1079
- function findChrome = () => string | null
1080
-
1081
- interface OpenOptions = {
1082
- timeout?: number
1083
- waitForConnection?: boolean
1084
- }
1085
-
1086
910
  ## ./compiler-helpers `(./src/compiler/helpers.ts)`
1087
911
 
1088
- function __esc = (v: unknown) => string
1089
-
1090
- function __escAttr = (v: unknown) => string
912
+ _(no exports)_
1091
913
 
1092
914
  ## ./store `(./src/store.ts)`
1093
915
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/flow",
3
- "version": "1.8.1",
3
+ "version": "1.10.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.8.1",
51
- "@zerotal/core": "1.8.1",
52
- "@zerotal/orm": "1.8.1",
53
- "@zerotal/validator": "1.8.1",
50
+ "@zerotal/client": "1.10.0",
51
+ "@zerotal/core": "1.10.0",
52
+ "@zerotal/orm": "1.10.0",
53
+ "@zerotal/validator": "1.10.0",
54
54
  "alpinejs": "^3.14.9",
55
55
  "typescript": "^5.8.0"
56
56
  },
57
57
  "devDependencies": {
58
- "@zerotal/broadcasting": "1.8.1",
58
+ "@zerotal/broadcasting": "1.10.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
@@ -68,6 +68,7 @@ export class ErrorField {
68
68
  // this.errors.has("field") → boolean
69
69
  // this.errors.field → ErrorField (for error={this.errors.field})
70
70
 
71
+ /** @internal */
71
72
  export interface ErrorsProxy {
72
73
  /** True when the given field has at least one error. */
73
74
  has(field: string): boolean;
@@ -265,6 +266,8 @@ function _autoChildKey(
265
266
  * The bag of side effects an action queued (flashes, a redirect, client scripts, events,
266
267
  * downloads, a title, and the current error bag). Drained by {@link Component._drainEffects}
267
268
  * and read by the WebSocket handler to build the response frames sent to the client.
269
+ *
270
+ * @internal
268
271
  */
269
272
  export interface FlowEffects {
270
273
  flashes: FlashMessage[];
@@ -303,21 +306,21 @@ export interface RedirectFlash {
303
306
  * for `level` (Tailwind/Notyf parlance).
304
307
  */
305
308
  export interface FlashOptions {
306
- level?: FlashLevel;
309
+ level?: FlashLevel | undefined;
307
310
  /** Alias for `level`. */
308
- type?: FlashLevel;
309
- title?: string;
310
- position?: FlashPosition;
311
+ type?: FlashLevel | undefined;
312
+ title?: string | undefined;
313
+ position?: FlashPosition | undefined;
311
314
  /** Auto-dismiss after N ms. `0` keeps the toast until dismissed. */
312
- duration?: number;
313
- dismissible?: boolean;
315
+ duration?: number | undefined;
316
+ dismissible?: boolean | undefined;
314
317
  /** Custom icon (emoji/text), or `false` to hide the status icon. */
315
- icon?: string | false;
316
- progressBar?: boolean;
318
+ icon?: string | false | undefined;
319
+ progressBar?: boolean | undefined;
317
320
  /** Action buttons that each invoke a `@expose` method on click. */
318
- actions?: FlashAction[];
321
+ actions?: FlashAction[] | undefined;
319
322
  /** A `@expose` method invoked when the toast is dismissed. */
320
- onClose?: FlashCallback;
323
+ onClose?: FlashCallback | undefined;
321
324
  }
322
325
 
323
326
  /**
package/src/Form.ts CHANGED
@@ -29,7 +29,11 @@ import type { FieldRule, Schema } from "@zerotal/validator";
29
29
  /** Registry of Form subclasses by name, used to reconstruct forms on hydration. */
30
30
  const _formClasses = new Map<string, new () => Form>();
31
31
 
32
- /** Register a Form subclass so it can be rebuilt from a snapshot. Called automatically. */
32
+ /**
33
+ * Register a Form subclass so it can be rebuilt from a snapshot. Called automatically.
34
+ *
35
+ * @internal
36
+ */
33
37
  export function registerForm(FormClass: new () => Form): void {
34
38
  _formClasses.set(FormClass.name, FormClass);
35
39
  }
@@ -29,14 +29,15 @@ import { CdpSession, findChrome, launchBrowser, type LaunchedBrowser } from "./c
29
29
  /** How long to wait for a condition driven by a server round-trip. */
30
30
  const DEFAULT_TIMEOUT_MS = 10_000;
31
31
 
32
+ /** @internal */
32
33
  export interface OpenOptions {
33
34
  /** Milliseconds to wait for the page to load and Flow to connect. */
34
- timeout?: number;
35
+ timeout?: number | undefined;
35
36
  /**
36
37
  * Wait for Flow's WebSocket to report itself online before returning.
37
38
  * Default: true. Set false for a page with no Flow component on it.
38
39
  */
39
- waitForConnection?: boolean;
40
+ waitForConnection?: boolean | undefined;
40
41
  }
41
42
 
42
43
  export class FlowBrowser {
@@ -42,6 +42,8 @@ const CHROME_PATHS: Record<string, string[]> = {
42
42
  * Returning `null` rather than throwing lets a suite skip itself on a machine
43
43
  * without a browser instead of failing — a browser test that cannot run is not
44
44
  * the same as one that failed.
45
+ *
46
+ * @internal
45
47
  */
46
48
  export function findChrome(): string | null {
47
49
  const override = Bun.env["CHROME_PATH"];
@@ -15,17 +15,17 @@ export interface ConfirmDialogOptions {
15
15
  /** Body text (the description, or the heading when no `title` is given). */
16
16
  message: string;
17
17
  /** Bold heading above the message. */
18
- title?: string;
18
+ title?: string | undefined;
19
19
  /** Confirm button label (default: "Confirm", or "Delete" for danger). */
20
- confirmLabel?: string;
20
+ confirmLabel?: string | undefined;
21
21
  /** Cancel button label (default: "Cancel"). */
22
- cancelLabel?: string;
22
+ cancelLabel?: string | undefined;
23
23
  /** `danger` = destructive (red); `primary` = neutral/indigo (default). */
24
- variant?: "danger" | "primary";
24
+ variant?: "danger" | "primary" | undefined;
25
25
  /** Custom icon (emoji/text/SVG markup), or `false` to hide the icon entirely. */
26
- icon?: string | false;
26
+ icon?: string | false | undefined;
27
27
  /** Show a type-to-confirm input; Confirm unlocks only when the value matches this. */
28
- prompt?: string;
28
+ prompt?: string | undefined;
29
29
  }
30
30
 
31
31
  const ICONS = {