);
}
queueStream({
childId,
render: async () => {
const streamCtx = HttpContext.tryGet();
await childPage.onBoot(streamCtx);
await childPage.onMount(streamCtx);
const inner = await _renderFlowPage(childPage, () => childPage.render());
await childPage.onDehydrate();
const snap = dehydrate(childPage, { id: childId, name, path: this._flowPath });
return (
inner +
``
);
},
});
return {
html:
`` +
`${childPage.placeholder().html}` +
`
`,
};
}
// Initial render (SSR) or a child newly added by this update: full render.
const childPage = new ChildClass();
childPage._flowId = childId;
childPage._flowPath = this._flowPath;
if (opts.slots) childPage._flowSlots = opts.slots;
if (opts.props) {
_assignChildProps(childPage, opts.props as Record);
}
// A child is populated from its parent's props (assigned above), not from route segments,
// but it still gets the request context — the same object the routed page received.
const ctx = HttpContext.tryGet();
await childPage.onBoot(ctx);
await childPage.onMount(ctx);
const innerHtml = await _renderFlowPage(childPage, () => childPage.render());
await childPage.onDehydrate();
const snapshot = dehydrate(childPage, {
id: childId,
name,
path: this._flowPath,
});
return {
html:
`` +
`${innerHtml}` +
`` +
`
`,
};
}
/**
* Render a `static interactive = false` child: markup, and nothing else.
*
* Same lifecycle as an interactive child up to the point where the client would
* be involved — `onBoot`, `onMount`, `render` — and then it stops. No
* `onDehydrate`, no snapshot, no state script, no wrapper.
*
* The absent wrapper is the load-bearing part. `data-flow-root` is what tells
* the client morph to leave a subtree alone, which is right for something with
* live state and exactly wrong here: this child's only route to an update is
* its parent re-rendering it, and a skipped subtree would never receive one.
*/
/** @internal Called only by {@link child}. */
async _renderStaticChild(
ChildClass: new () => C,
opts: { props?: Partial; slots?: Record },
): Promise {
const childPage = new ChildClass();
childPage._flowPath = this._flowPath;
if (opts.slots) childPage._flowSlots = opts.slots;
if (opts.props) {
_assignChildProps(childPage, opts.props as Record);
}
const ctx = HttpContext.tryGet();
await childPage.onBoot(ctx);
await childPage.onMount(ctx);
return _renderFlowPage(childPage, () => childPage.render());
}
// ── Streaming ─────────────────────────────────────────────────────────────
/**
* Push content to the client MID-ACTION — before the final patch — into any
* element marked `flow:stream="ref"`. Appends by default; pass
* `{ replace: true }` to overwrite instead.
*
* Works only during WebSocket actions (no-op during SSR).
*
* @security `content` is injected as raw innerHTML on the client. Never pass
* unescaped user input directly — sanitize or escape it first, or use a
* trusted HTML sanitizer (e.g. DOMPurify) client-side.
*
* @example
* @expose async generate() {
* for await (const token of llm.stream(this.prompt)) {
* this.stream('answer', token);
* }
* }
*
* @param ref the `flow:stream="ref"` target element.
* @param content raw HTML appended (or replacing) inside the target.
* @param opts pass `{ replace: true }` to overwrite instead of append.
* @category Actions
*/
stream(ref: string, content: string, opts: { replace?: boolean } = {}): void {
this._streamSender?.(ref, content, opts.replace ?? false);
}
// ── Built-in actions (invoked by the bridge, not user-callable) ───────────
/**
* @internal Handles flow:model updates from the client.
* Silently ignores unexposed or @locked properties — both are defense-in-depth
* against confused-deputy attacks (HMAC signing is the primary guard).
*/
$set(key: string, value: unknown): void {
const exposed = getExposedProps(this);
const locked = getLockedProps(this);
if (!exposed.has(key) && !locked.has(key)) return;
if (locked.has(key)) return;
// Form object: fill the existing instance instead of replacing it with a plain
// object, so its class + methods survive a `value={this.form.field}` edit.
const cur = (this as Record)[key];
if (
cur &&
typeof cur === "object" &&
(cur as { __isFlowForm?: unknown }).__isFlowForm === true &&
value &&
typeof value === "object"
) {
(cur as { fill(v: Record): void }).fill(value as Record);
return;
}
// Model: same reasoning as the form above — fill the hydrated instance rather than
// replacing it with the plain object the client sent, so its class, methods and dirty
// tracking survive a `value={this.user.name}` edit. `_applyWireValues` keeps only
// `fillable` minus `hidden`, so a field the model never offered is ignored rather than
// written or thrown over.
if (
cur &&
typeof cur === "object" &&
_isRegisteredModel(cur) &&
value &&
typeof value === "object"
) {
_applyWireValues(cur, value);
return;
}
// Signed upload references become TemporaryUploadedFile instances (signature verified);
// all other values pass through unchanged.
(this as Record)[key] = resolveUploadValue(value);
}
/** @internal Triggers onMount re-run (used by $flow.refresh()). */
$refresh(): void {
this._shouldRefresh = true;
}
/**
* @internal Apply a client-driven property update, firing the updating/updated
* lifecycle hooks (generic + per-property) around the write. Only writable
* (`@expose`, non-`@locked`) props fire hooks and get written; everything else
* is silently ignored by `$set` (defense-in-depth). An `onUpdating` hook may
* throw to reject the update.
*/
async _applyClientUpdate(key: string, value: unknown, arrayKey?: string): Promise {
const exposed = getExposedProps(this);
const locked = getLockedProps(this);
if (!exposed.has(key) || locked.has(key)) {
this.$set(key, value); // no-op for unknown/locked, kept for symmetry
return;
}
await this._emitUpdateHook("onUpdating", key, value, arrayKey);
this.$set(key, value);
await this._emitUpdateHook("onUpdated", key, value, arrayKey);
// Real-time validation: if this field carries a `@validate` rule, validate
// just it now and set/clear its error. The changed value reaches here from `flow:model.live` /
// `.blur` syncs (and dirty-field batches), so errors appear/clear as the user edits — the
// refreshed `_errors` bag is sent to the client in this round-trip's patch. Never throws.
await this._validateField(key);
}
/** @internal Fire a generic update hook + its per-property `on…` variant. */
async _emitUpdateHook(
base: "onUpdating" | "onUpdated",
prop: string,
value: unknown,
key?: string,
): Promise {
await (this[base] as (p: string, v: unknown, k?: string) => Promise)(prop, value, key);
const specific = `${base}${prop.charAt(0).toUpperCase()}${prop.slice(1)}`;
const fn = (this as Record)[specific];
if (typeof fn === "function") {
await (fn as (v: unknown, k?: string) => unknown).call(this, value, key);
}
}
// ── Effect draining (called by WS handler after action) ──────────────────
/**
* The title to send with this frame — `static title` resolved against this component.
*
* Resolved per frame rather than once, which is what lets the function form track state:
* change what it reads and the next patch carries the new title.
*
* @internal
*/
_resolveTitle(): string | null {
const declared = (this.constructor as { title?: unknown }).title;
if (typeof declared === "function") {
return String((declared as (c: unknown) => unknown)(this));
}
return typeof declared === "string" ? declared : null;
}
/** @internal — read by the WS handler after action execution. */
_drainEffects(): FlowEffects {
const effects: FlowEffects = {
flashes: this._flashes.splice(0),
redirectUrl: this._redirectUrl,
shouldRefresh: this._shouldRefresh,
scripts: this._clientScripts.splice(0),
errors: this._errors,
events: this._events.splice(0),
downloads: this._downloads.splice(0),
title: this._resolveTitle(),
};
this._redirectUrl = null;
this._redirectStatus = null;
this._shouldRefresh = false;
// _errors intentionally NOT reset here: errors persist across actions so the
// client continues showing field messages after e.g. a failed save that then
// dispatches a flash. Call resetValidation() or a passing validate() to clear.
return effects;
}
}