@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/CHANGELOG.md +317 -4
- package/README.md +1 -1
- package/package.json +8 -9
- package/src/Component.ts +214 -41
- package/src/browser/FlowBrowser.ts +270 -0
- package/src/browser/cdp.ts +273 -0
- package/src/browser/index.ts +19 -0
- package/src/client/FlowComponent.ts +1 -1
- package/src/client/bridge.ts +345 -38
- package/src/client/headless.ts +2 -1
- package/src/compiler/directives.ts +1 -0
- package/src/compiler/transform.ts +4 -0
- package/src/components.ts +286 -4
- package/src/config.ts +2 -2
- package/src/decorators.ts +122 -35
- package/src/dehydrate.ts +2 -2
- package/src/events.ts +1 -1
- package/src/index.ts +8 -2
- package/src/jsx-runtime.ts +90 -7
- package/src/pagination.ts +5 -7
- package/src/provider/FlowProvider.ts +46 -13
- package/src/router.ts +186 -116
- package/src/sections.ts +121 -0
- package/src/store.ts +0 -1
- package/src/streaming.ts +92 -0
- package/src/synths/index.ts +2 -2
- package/src/testing.ts +1 -1
- package/src/types.ts +1 -1
- package/src/uploads/FileUploads.ts +3 -4
- package/src/uploads/TemporaryUploadedFile.ts +1 -1
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, "&").replace(/'/g, "'").replace(/</g, "<");
|
|
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.
|
|
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.
|
|
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()`.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
858
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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:
|
|
872
|
-
params
|
|
970
|
+
redirectRoute<N extends RouteTarget>(
|
|
971
|
+
name: N,
|
|
972
|
+
params?: RouteParamsArg<N>,
|
|
873
973
|
status?: number,
|
|
874
974
|
): RedirectFlash {
|
|
875
|
-
|
|
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
|
|
880
|
-
*
|
|
881
|
-
*
|
|
882
|
-
*
|
|
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
|
|
1045
|
-
*
|
|
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).
|
|
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.
|
|
1284
|
-
*
|
|
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`
|
|
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
|
-
|
|
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
|
-
:
|
|
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
|
|
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
|
|
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.
|