@zerotal/flow 1.3.0 → 1.5.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/src/Component.ts CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  getModelableProps,
17
17
  } from "./decorators.ts";
18
18
  import { _renderFlowPage } from "./jsx-runtime.ts";
19
+ import { getStreamStore, queueStream } from "./streaming.ts";
19
20
  import { RuleBuilder, runValidationAsync } from "@zerotal/validator";
20
21
  import type { Schema, FieldRuleDefinition } from "@zerotal/validator";
21
22
  import type { ValidateBuilder } from "./decorators.ts";
@@ -28,6 +29,7 @@ import type { Compose } from "./mixins.ts";
28
29
  import { _validateEventPayload } from "./events.ts";
29
30
  import type { FlowEvents, EventName, EventArgs } from "./events.ts";
30
31
  import { route, request, safeRedirectPath, HttpContext } from "@zerotal/core";
32
+ import type { RouteParamValues, RouteParamsArg, RouteTarget } from "@zerotal/core";
31
33
 
32
34
  /**
33
35
  * Convert the validator's one-message-per-field result (`Record<string, string>`) into
@@ -135,6 +137,99 @@ function _attrJson(s: string): string {
135
137
  return s.replace(/&/g, "&amp;").replace(/'/g, "&#39;").replace(/</g, "&lt;");
136
138
  }
137
139
 
140
+ // ── Keyless child identity ───────────────────────────────────────────────────
141
+
142
+ /** JSON with object keys in a stable order, so `{a,b}` and `{b,a}` hash alike. */
143
+ function _stableJson(value: unknown): string {
144
+ if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
145
+ if (Array.isArray(value)) return `[${value.map(_stableJson).join(",")}]`;
146
+ const entries = Object.keys(value as Record<string, unknown>)
147
+ .sort()
148
+ .map((k) => `${JSON.stringify(k)}:${_stableJson((value as Record<string, unknown>)[k])}`);
149
+ return `{${entries.join(",")}}`;
150
+ }
151
+
152
+ /**
153
+ * FNV-1a, 32-bit, as fixed-width hex.
154
+ *
155
+ * Fixed width is the point: the occurrence counter is appended straight after
156
+ * it, so a variable-length hash would make `<hash>1` and `<hash>10` ambiguous.
157
+ * Not cryptographic — a collision costs two children sharing an id, the same
158
+ * cost as the occurrence index they replace.
159
+ */
160
+ function _fnv1a(input: string): string {
161
+ let hash = 0x811c9dc5;
162
+ for (let i = 0; i < input.length; i++) {
163
+ hash ^= input.charCodeAt(i);
164
+ hash = Math.imul(hash, 0x01000193) >>> 0;
165
+ }
166
+ return hash.toString(16).padStart(8, "0");
167
+ }
168
+
169
+ function _isProduction(): boolean {
170
+ return (Bun.env["APP_ENV"] ?? Bun.env["NODE_ENV"]) === "production";
171
+ }
172
+
173
+ /** Classes already warned about, so a hundred-row list logs once. */
174
+ const _warnedKeyless = new Set<string>();
175
+
176
+ function _warnKeylessChild(className: string): void {
177
+ if (_warnedKeyless.has(className)) return;
178
+ _warnedKeyless.add(className);
179
+ console.warn(
180
+ `[Zerotal Flow] <${className}> is rendered more than once with identical props and no \`key\`. ` +
181
+ `Two children with the same id share one island: the second inherits the first's DOM and ` +
182
+ `state. Pass a key tied to the row's own identity — child(${className}, { key: item.id }).`,
183
+ );
184
+ }
185
+
186
+ /** Reset the once-per-class warning set. Tests. @internal */
187
+ export function _resetKeylessWarnings(): void {
188
+ _warnedKeyless.clear();
189
+ }
190
+
191
+ /**
192
+ * The id suffix for a child rendered without an explicit `key`.
193
+ *
194
+ * `<hash of the seed props><occurrence within that hash>`. The hash is a fixed
195
+ * eight characters so the counter appended after it is unambiguous, and it is
196
+ * computed from the props that *identify* the child rather than the ones that
197
+ * update it — @reactive and @modelable props exist to change without remounting,
198
+ * and folding them in would trade a shifted-list bug for a child that resets on
199
+ * every parent update.
200
+ *
201
+ * A module function rather than a method: `Component` is extended by mixins that
202
+ * export anonymous classes, and a `private` member on one of those is a
203
+ * declaration error (TS4094).
204
+ *
205
+ * @internal
206
+ */
207
+ function _autoChildKey(
208
+ childIds: readonly string[],
209
+ base: string,
210
+ props: Record<string, unknown>,
211
+ reactiveKeys: ReadonlySet<string>,
212
+ modelableKeys: ReadonlySet<string>,
213
+ className: string,
214
+ ): string {
215
+ const identity: Record<string, unknown> = {};
216
+ for (const key of Object.keys(props)) {
217
+ if (reactiveKeys.has(key) || modelableKeys.has(key)) continue;
218
+ identity[key] = props[key];
219
+ }
220
+
221
+ const hash = _fnv1a(_stableJson(identity));
222
+ const prefix = `${base}${hash}`;
223
+ const occurrence = childIds.filter((id) => id.startsWith(prefix)).length;
224
+
225
+ // Only the genuinely ambiguous case warns. A keyless child whose props differ
226
+ // from its siblings is now content-addressed and safe, so warning there would
227
+ // be noise; two siblings the framework cannot tell apart is the residual risk.
228
+ if (occurrence > 0 && !_isProduction()) _warnKeylessChild(className);
229
+
230
+ return `${hash}${occurrence}`;
231
+ }
232
+
138
233
  // ── Internal effect bags ──────────────────────────────────────────────────────
139
234
 
140
235
  /**
@@ -564,7 +659,7 @@ export abstract class Component {
564
659
  * HTTP render and every subsequent WebSocket round-trip — after state is restored
565
660
  * (round-trips) or `@url`/`@session` seeded (initial), but before `onMount()` /
566
661
  * `onHydrate()`, property updates, and the action. Use for setup that must run on
567
- * every request. (Livewire `boot()`.) Receives the route {@link HttpContext} — note that on
662
+ * every request. Receives the route {@link HttpContext} — note that on
568
663
  * WebSocket round-trips the URL is the stored route pattern, so raw `ctx.params` are
569
664
  * only populated on the initial GET; prefer `onMount()` for reading bound models.
570
665
  *
@@ -576,7 +671,7 @@ export abstract class Component {
576
671
  * Called at the beginning of every SUBSEQUENT (WebSocket) request, right after the
577
672
  * component is rebuilt from its snapshot — never on the initial render (use
578
673
  * `onMount()` for that). Ideal for re-deriving non-persisted/protected state from
579
- * restored properties. (Livewire `hydrate()`.)
674
+ * restored properties.
580
675
  *
581
676
  * @category Lifecycle
582
677
  */
@@ -585,7 +680,6 @@ export abstract class Component {
585
680
  /**
586
681
  * Called at the END of every request, just before the component is serialised into
587
682
  * its snapshot. Use to normalise state back into a serialisable shape.
588
- * (Livewire `dehydrate()`.)
589
683
  *
590
684
  * @category Lifecycle
591
685
  */
@@ -595,7 +689,7 @@ export abstract class Component {
595
689
  * Called BEFORE a client-driven property update is applied (a `value`/`checked`
596
690
  * input or `$flow.$set`). Throw to reject the update. Only fires for writable
597
691
  * (`@expose`) properties. For a single property, define `onUpdating<Prop>(value, key?)`
598
- * — e.g. `onUpdatingEmail()`. (Livewire `updating()`.)
692
+ * — e.g. `onUpdatingEmail()`.
599
693
  *
600
694
  * @param prop the property about to change
601
695
  * @param value the incoming value
@@ -608,7 +702,7 @@ export abstract class Component {
608
702
  /**
609
703
  * Called AFTER a client-driven property update is applied. For a single property,
610
704
  * define `onUpdated<Prop>(value, key?)` — e.g. `onUpdatedUsername()` to normalise a
611
- * value such as lower-casing. (Livewire `updated()`.)
705
+ * value such as lower-casing.
612
706
  *
613
707
  * @category Lifecycle
614
708
  */
@@ -616,14 +710,14 @@ export abstract class Component {
616
710
  async onUpdated(_prop: string, _value: unknown, _key?: string): Promise<void> {}
617
711
 
618
712
  /**
619
- * Called BEFORE `render()` runs. (Livewire `rendering()`.)
713
+ * Called BEFORE `render()` runs.
620
714
  *
621
715
  * @category Lifecycle
622
716
  */
623
717
  async onRendering(): Promise<void> {}
624
718
 
625
719
  /**
626
- * Called AFTER `render()` produces this component's HTML. (Livewire `rendered()`.)
720
+ * Called AFTER `render()` produces this component's HTML.
627
721
  * @param html the rendered HTML for this component
628
722
  * @category Lifecycle
629
723
  */
@@ -633,7 +727,7 @@ export abstract class Component {
633
727
  /**
634
728
  * Called when an action throws an unhandled error. Flow catches the error and
635
729
  * still re-renders; override to customise (log, swallow, re-flash). The default
636
- * flashes the message. (Livewire `exception()`.)
730
+ * flashes the message.
637
731
  *
638
732
  * @param error the error thrown by the action.
639
733
  * @category Errors
@@ -831,6 +925,9 @@ export abstract class Component {
831
925
  redirect(url: string, status?: number): RedirectFlash {
832
926
  this._redirectUrl = url;
833
927
  this._redirectStatus = status ?? null;
928
+ // Two `this` scopes meet here: the builder methods chain on the builder while
929
+ // flashing through the component, so the alias is load-bearing.
930
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
834
931
  const self = this;
835
932
  const builder: RedirectFlash = {
836
933
  with(message: string, level: FlashLevel) {
@@ -854,32 +951,37 @@ export abstract class Component {
854
951
  }
855
952
 
856
953
  /**
857
- * Redirect to a named route, mirroring Livewire's `redirectRoute('profile', ['id' => 1])`.
858
- * Route params fill `:segment` placeholders; any extra keys become query-string params.
954
+ * Redirect to a named route.
955
+ *
956
+ * Params fill `:segment` placeholders — one key per segment, no more: a key
957
+ * the pattern has no segment for throws rather than becoming a query param.
859
958
  *
860
959
  * @example
861
960
  * return this.redirectRoute("profile", { id: 1 }).withSuccess("Saved.");
862
- * this.redirectRoute("posts.show", { slug: "hello", ref: "email" }); // /posts/hello?ref=email
961
+ * // With a query string, build the URL first:
962
+ * this.redirect(route("posts.show", { slug: "hello" }, { ref: "email" }));
863
963
  *
864
964
  * @param name The route name registered with `.name(...)`.
865
- * @param params Route + query params.
965
+ * @param params One value per `:param` in the route's pattern.
866
966
  * @param status Optional HTTP-style status (advisory — the client navigates regardless).
867
967
  * @returns a {@link RedirectFlash} builder for chaining a flash onto the redirect.
868
968
  * @category Navigation & redirects
869
969
  */
870
- redirectRoute(
871
- name: string,
872
- params: Record<string, string | number> = {},
970
+ redirectRoute<N extends RouteTarget>(
971
+ name: N,
972
+ params?: RouteParamsArg<N>,
873
973
  status?: number,
874
974
  ): RedirectFlash {
875
- return this.redirect(route(name, params), status);
975
+ // Values in `RouteParams<N>` are route param values by construction; `N` is
976
+ // still a type variable here, so the compiler cannot see through to say so.
977
+ return this.redirect(route.dynamic(name, params as RouteParamValues), status);
876
978
  }
877
979
 
878
980
  /**
879
- * Redirect the user back to where they were headed before being intercepted — Livewire's
880
- * `redirectIntended('/default/url')`. Reads (and clears) the `intended_url` stored in the
881
- * session by `AuthMiddleware`, falling back to `fallback` when none is stored or the stored
882
- * URL is cross-origin (open-redirect guard).
981
+ * Redirect the user back to where they were headed before being intercepted. Reads
982
+ * (and clears) the `intended_url` stored in the session by `AuthMiddleware`, falling
983
+ * back to `fallback` when none is stored or the stored URL is cross-origin
984
+ * (open-redirect guard).
883
985
  *
884
986
  * @example
885
987
  * if (await Auth.attempt(creds)) return this.redirectIntended("/dashboard");
@@ -1041,8 +1143,8 @@ export abstract class Component {
1041
1143
  }
1042
1144
 
1043
1145
  /**
1044
- * @internal Real-time validation of a SINGLE field against its `@validate` rule (Livewire's
1045
- * live validation). Sets or clears `this._errors[field]` without throwing, so it can run on
1146
+ * @internal Real-time validation of a SINGLE field against its `@validate` rule.
1147
+ * Sets or clears `this._errors[field]` without throwing, so it can run on
1046
1148
  * every client update. No-op for fields without a `@validate` rule.
1047
1149
  */
1048
1150
  async _validateField(field: string): Promise<void> {
@@ -1115,7 +1217,7 @@ export abstract class Component {
1115
1217
 
1116
1218
  /**
1117
1219
  * Dispatch an event ONLY to components of a given class name (skips all others
1118
- * listening for the same event). Mirrors Livewire's `dispatch()->to(...)`.
1220
+ * listening for the same event).
1119
1221
  *
1120
1222
  * @example
1121
1223
  * this.dispatchTo('Dashboard', 'post-created', { id: post.id });
@@ -1134,7 +1236,6 @@ export abstract class Component {
1134
1236
 
1135
1237
  /**
1136
1238
  * Dispatch an event only to THIS component (it won't bubble to others).
1137
- * Mirrors Livewire's `dispatch()->self()`.
1138
1239
  *
1139
1240
  * @example
1140
1241
  * this.dispatchSelf('refresh');
@@ -1280,14 +1381,24 @@ export abstract class Component {
1280
1381
  *
1281
1382
  * Children are islands: a parent re-render does NOT re-render existing
1282
1383
  * children (their DOM and state are preserved on the client), and a child
1283
- * update never touches the parent. Use `key` when embedding the same
1284
- * component class multiple times (e.g. in a loop).
1384
+ * update never touches the parent.
1385
+ *
1386
+ * **Pass a `key` for anything rendered in a loop.** Without one the id is
1387
+ * derived from the child's seed props, which is stable enough that a row
1388
+ * keeps its island when the list shifts — but two siblings the props cannot
1389
+ * tell apart still share an id, and sharing an id means sharing DOM and
1390
+ * state. A key tied to the row's own identity (`key: item.id`) removes the
1391
+ * question. Keys are sanitised to `[a-zA-Z0-9_-]`, so `a.b` and `ab` collide.
1285
1392
  *
1286
1393
  * `props` are assigned to the child instance before `onMount()` runs.
1287
1394
  *
1288
1395
  * Pass `lazy: true` to use IntersectionObserver — `onMount()` is deferred
1289
1396
  * until the placeholder enters the viewport.
1290
1397
  * Pass `defer: true` to load immediately after page paint (no intersection check).
1398
+ * Pass `stream: true` to render the placeholder now and the real markup later
1399
+ * on the *same* response — no second round trip. Use it for content that is
1400
+ * definitely needed and merely slow; `lazy`/`defer` are for content that may
1401
+ * never be needed at all.
1291
1402
  *
1292
1403
  * @example
1293
1404
  * override async render() {
@@ -1296,13 +1407,14 @@ export abstract class Component {
1296
1407
  * <StatsWidget />
1297
1408
  * <CounterWidget key="a" step={5} />
1298
1409
  * <SlowWidget lazy />
1410
+ * <SalesReport stream />
1299
1411
  * </div>;
1300
1412
  * }
1301
1413
  *
1302
1414
  * @param ChildClass the child Component class to embed.
1303
1415
  * @param opts `key` disambiguates repeated instances; `props` seed the child before
1304
- * `onMount()`; `lazy`/`defer` render a placeholder first; `slots` pass named
1305
- * slot HTML.
1416
+ * `onMount()`; `lazy`/`defer`/`stream` render a placeholder first; `slots`
1417
+ * pass named slot HTML.
1306
1418
  * @returns the child's rendered {@link HtmlNode} (root or placeholder).
1307
1419
  * @category Rendering
1308
1420
  */
@@ -1313,17 +1425,46 @@ export abstract class Component {
1313
1425
  props?: Partial<C>;
1314
1426
  lazy?: boolean;
1315
1427
  defer?: boolean;
1428
+ /** Render the placeholder now, the real markup later on the same response. */
1429
+ stream?: boolean;
1316
1430
  /** Named slot HTML (`name → html`, default slot keyed `"default"`), rendered by the parent. */
1317
1431
  slots?: Record<string, string>;
1318
1432
  } = {},
1319
1433
  ): Promise<HtmlNode> {
1320
1434
  const name = ChildClass.name;
1321
1435
  const base = `${this._flowId}-${name.toLowerCase()}-`;
1322
- // Deterministic id: explicit key, or occurrence index within this render.
1436
+
1437
+ // ── Reactive / modelable bindings (Tier 1) ───────────────────────────────
1438
+ // `data-flow-props` carries the current value of each @reactive prop so the
1439
+ // client can detect a parent-pushed change and re-render the child. For each
1440
+ // @modelable prop, `data-flow-model` maps the child prop → the parent property
1441
+ // it syncs back to (resolved by value identity against the parent's props).
1442
+ const childProto = (ChildClass as { prototype: object }).prototype;
1443
+ const reactiveKeys = getReactiveProps(childProto);
1444
+ const modelableKeys = getModelableProps(childProto);
1445
+ const givenProps = (opts.props ?? {}) as Record<string, unknown>;
1446
+
1447
+ // Deterministic id: an explicit key, or the child's seed props plus an
1448
+ // occurrence counter.
1449
+ //
1450
+ // A bare occurrence index is *positional* identity, and position is not
1451
+ // identity: remove the first item of a list and every row below it inherits
1452
+ // the id its neighbour had. Because a hydrated parent emits an already-known
1453
+ // child as an empty stub — on the understanding that the client morph will
1454
+ // recognise the pairing — every row then adopts the previous row's live DOM,
1455
+ // and the last one is left blank. Nothing warns, and no server-side test can
1456
+ // see it: SSR and `FlowTest` render the full child every time.
1457
+ //
1458
+ // Folding the seed props into the id makes the pairing content-addressed, so
1459
+ // a shifted row simply fails to match and is rendered in full — re-mounted
1460
+ // rather than crossed with its neighbour. @reactive and @modelable props are
1461
+ // excluded deliberately: those exist to change without remounting the child,
1462
+ // and hashing them would trade this bug for a child that resets on every
1463
+ // update. The counter still disambiguates genuinely identical siblings.
1323
1464
  const keyPart =
1324
1465
  opts.key !== undefined
1325
1466
  ? String(opts.key).replace(/[^a-zA-Z0-9_-]/g, "")
1326
- : String(this._childIds.filter((id) => id.startsWith(base)).length);
1467
+ : _autoChildKey(this._childIds, base, givenProps, reactiveKeys, modelableKeys, name);
1327
1468
  const childId = `${base}${keyPart}`;
1328
1469
 
1329
1470
  this._childIds.push(childId);
@@ -1333,15 +1474,6 @@ export abstract class Component {
1333
1474
  const { registerComponent } = await import("./registry.ts");
1334
1475
  registerComponent(ChildClass as never, this._flowPath);
1335
1476
 
1336
- // ── Reactive / modelable bindings (Tier 1) ───────────────────────────────
1337
- // `data-flow-props` carries the current value of each @reactive prop so the
1338
- // client can detect a parent-pushed change and re-render the child. For each
1339
- // @modelable prop, `data-flow-model` maps the child prop → the parent property
1340
- // it syncs back to (resolved by value identity against the parent's props).
1341
- const childProto = (ChildClass as { prototype: object }).prototype;
1342
- const reactiveKeys = getReactiveProps(childProto);
1343
- const modelableKeys = getModelableProps(childProto);
1344
- const givenProps = (opts.props ?? {}) as Record<string, unknown>;
1345
1477
  let bindAttrs = "";
1346
1478
  if (reactiveKeys.size) {
1347
1479
  const propsObj: Record<string, unknown> = {};
@@ -1371,7 +1503,7 @@ export abstract class Component {
1371
1503
 
1372
1504
  // Parent re-render over WS: children that already exist client-side are
1373
1505
  // emitted as stubs — the client morph skips nested roots, so the child's live
1374
- // DOM and state are preserved (Livewire pattern). The stub still carries
1506
+ // DOM and state are preserved. The stub still carries
1375
1507
  // data-flow-props so reactive/modelable updates reach the existing child.
1376
1508
  if (this._isHydrated && this._prevChildIds.includes(childId)) {
1377
1509
  return {
@@ -1410,6 +1542,47 @@ export abstract class Component {
1410
1542
  };
1411
1543
  }
1412
1544
 
1545
+ // Streamed: paint the placeholder now and queue the real render to be
1546
+ // appended to this same response once the shell has been flushed. Only
1547
+ // possible during an initial GET — a WS patch has no open response to append
1548
+ // to, so `getStreamStore()` is undefined there and this falls through to the
1549
+ // ordinary inline render below.
1550
+ const streamStore = opts.stream ? getStreamStore() : undefined;
1551
+ if (streamStore) {
1552
+ const childPage = new ChildClass();
1553
+ childPage._flowId = childId;
1554
+ childPage._flowPath = this._flowPath;
1555
+ if (opts.slots) childPage._flowSlots = opts.slots;
1556
+ if (opts.props) {
1557
+ for (const [k, v] of Object.entries(opts.props)) {
1558
+ if (!k.startsWith("_")) (childPage as Record<string, unknown>)[k] = v;
1559
+ }
1560
+ }
1561
+
1562
+ queueStream({
1563
+ childId,
1564
+ render: async () => {
1565
+ const streamCtx = HttpContext.tryGet();
1566
+ await childPage.onBoot(streamCtx);
1567
+ await childPage.onMount(streamCtx);
1568
+ const inner = await _renderFlowPage(childPage, () => childPage.render());
1569
+ await childPage.onDehydrate();
1570
+ const snap = dehydrate(childPage, { id: childId, name, path: this._flowPath });
1571
+ return (
1572
+ inner +
1573
+ `<script type="application/json" id="flow-state-${childId}">${toScriptJson(snap)}</script>`
1574
+ );
1575
+ },
1576
+ });
1577
+
1578
+ return {
1579
+ html:
1580
+ `<div data-flow-root x-data="{}" data-flow-id="${childId}" data-flow-name="${name}" data-flow-streaming${bindAttrs}>` +
1581
+ `${childPage.placeholder().html}` +
1582
+ `</div>`,
1583
+ };
1584
+ }
1585
+
1413
1586
  // Initial render (SSR) or a child newly added by this update: full render.
1414
1587
  const childPage = new ChildClass();
1415
1588
  childPage._flowId = childId;
@@ -1526,7 +1699,7 @@ export abstract class Component {
1526
1699
  this.$set(key, value);
1527
1700
  await this._emitUpdateHook("onUpdated", key, value, arrayKey);
1528
1701
 
1529
- // Real-time validation (Livewire-style): if this field carries a `@validate` rule, validate
1702
+ // Real-time validation: if this field carries a `@validate` rule, validate
1530
1703
  // just it now and set/clear its error. The changed value reaches here from `flow:model.live` /
1531
1704
  // `.blur` syncs (and dirty-field batches), so errors appear/clear as the user edits — the
1532
1705
  // refreshed `_errors` bag is sent to the client in this round-trip's patch. Never throws.