@zerotal/flow 1.0.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 +61 -0
- package/LICENSE +21 -0
- package/README.md +148 -0
- package/package.json +84 -0
- package/src/Component.ts +1498 -0
- package/src/Form.ts +189 -0
- package/src/Layout.ts +47 -0
- package/src/augment.ts +94 -0
- package/src/client/FlowClientError.ts +12 -0
- package/src/client/FlowComponent.ts +96 -0
- package/src/client/alpinejs-morph.d.ts +5 -0
- package/src/client/bridge.ts +3463 -0
- package/src/client/confirm.ts +338 -0
- package/src/client/cspAdapter.ts +79 -0
- package/src/client/cspEvaluator.ts +494 -0
- package/src/client/errorOverlay.ts +124 -0
- package/src/client/headless.ts +948 -0
- package/src/client/index.csp.ts +47 -0
- package/src/client/index.ts +56 -0
- package/src/client/optimistic.ts +52 -0
- package/src/client/timeline.ts +160 -0
- package/src/client/url.ts +51 -0
- package/src/commands/MakeFlowCommand.ts +290 -0
- package/src/compiler/cache.ts +101 -0
- package/src/compiler/directives.ts +107 -0
- package/src/compiler/helpers.ts +26 -0
- package/src/compiler/index.ts +172 -0
- package/src/compiler/transform.ts +2070 -0
- package/src/components.ts +1465 -0
- package/src/config.ts +52 -0
- package/src/decorators.ts +978 -0
- package/src/dehydrate.ts +333 -0
- package/src/durable.ts +243 -0
- package/src/events.ts +118 -0
- package/src/frameworkEvents.ts +40 -0
- package/src/headless.ts +1147 -0
- package/src/index.ts +267 -0
- package/src/jsx-dev-runtime.ts +14 -0
- package/src/jsx-runtime.ts +1272 -0
- package/src/mixins.ts +199 -0
- package/src/observability.ts +167 -0
- package/src/pagination.ts +300 -0
- package/src/presence.ts +81 -0
- package/src/provider/FlowProvider.ts +1401 -0
- package/src/registry.ts +118 -0
- package/src/renderContext.ts +55 -0
- package/src/router.ts +401 -0
- package/src/shared.ts +226 -0
- package/src/store.ts +116 -0
- package/src/synths/CarbonIntervalSynth.ts +22 -0
- package/src/synths/CarbonSynth.ts +20 -0
- package/src/synths/CollectionSynth.ts +62 -0
- package/src/synths/DateSynth.ts +24 -0
- package/src/synths/ModelSynth.ts +78 -0
- package/src/synths/TemporaryUploadedFileSynth.ts +19 -0
- package/src/synths/index.ts +148 -0
- package/src/testing.ts +361 -0
- package/src/types.ts +315 -0
- package/src/uploads/FileUploads.ts +94 -0
- package/src/uploads/TemporaryUploadedFile.ts +274 -0
- package/src/uploads/sign.ts +18 -0
- package/src/urlSafety.ts +0 -0
- package/src/utils.ts +216 -0
- package/src/validation.ts +55 -0
package/src/Form.ts
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// A Form bundles related fields + their validation rules + reset/fill helpers into
|
|
2
|
+
// a reusable class, mounted on a component as a single @expose property:
|
|
3
|
+
//
|
|
4
|
+
// class LoginForm extends Form {
|
|
5
|
+
// email = '';
|
|
6
|
+
// password = '';
|
|
7
|
+
// rules(v: RuleBuilder) {
|
|
8
|
+
// return { email: v.string().email(), password: v.string().min(8) };
|
|
9
|
+
// }
|
|
10
|
+
// }
|
|
11
|
+
//
|
|
12
|
+
// class LoginPage extends Component {
|
|
13
|
+
// @expose form = new LoginForm();
|
|
14
|
+
// @expose async submit() {
|
|
15
|
+
// this.validate(this.form); // validates form.data() against form.rules
|
|
16
|
+
// await Auth.attempt(this.form.data());
|
|
17
|
+
// }
|
|
18
|
+
// }
|
|
19
|
+
//
|
|
20
|
+
// Validation uses the built-in @zerotal/validator (its fluent RuleBuilder schema).
|
|
21
|
+
// Bind fields with `value={this.form.email}` (compiled to flow:model="form.email").
|
|
22
|
+
// Forms round-trip through a synthesizer so the class + methods survive hydration.
|
|
23
|
+
|
|
24
|
+
import { ValidationError } from "./validation.ts";
|
|
25
|
+
import { registerSynth } from "./synths/index.ts";
|
|
26
|
+
import { RuleBuilder, runValidation } from "@zerotal/validator";
|
|
27
|
+
import type { FieldRule, Schema } from "@zerotal/validator";
|
|
28
|
+
|
|
29
|
+
/** Registry of Form subclasses by name, used to reconstruct forms on hydration. */
|
|
30
|
+
const _formClasses = new Map<string, new () => Form>();
|
|
31
|
+
|
|
32
|
+
/** Register a Form subclass so it can be rebuilt from a snapshot. Called automatically. */
|
|
33
|
+
export function registerForm(FormClass: new () => Form): void {
|
|
34
|
+
_formClasses.set(FormClass.name, FormClass);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const _RESERVED = new Set(["rules"]);
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A reusable bundle of related form fields, their validation rules, and fill/reset helpers,
|
|
41
|
+
* mounted on a {@link Component} as a single `@expose` property.
|
|
42
|
+
*
|
|
43
|
+
* @remarks
|
|
44
|
+
* Declare fields as plain instance properties and their validation in {@link rules} (built with
|
|
45
|
+
* `@zerotal/validator`'s fluent {@link RuleBuilder}). Bind inputs with `value={this.form.email}`
|
|
46
|
+
* (the compiler rewrites it to `flow:model="form.email"`). Validate either from the component
|
|
47
|
+
* (`this.validate(this.form)`) or directly ({@link validate}); both share the same engine and
|
|
48
|
+
* error shape. A Form round-trips through a synthesizer, so its class and methods survive
|
|
49
|
+
* hydration — the subclass must be imported (instantiated at least once) on the server.
|
|
50
|
+
*
|
|
51
|
+
* @example
|
|
52
|
+
* ```tsx
|
|
53
|
+
* import { Component, expose, Form } from "@zerotal/flow";
|
|
54
|
+
* import type { RuleBuilder } from "@zerotal/validator";
|
|
55
|
+
*
|
|
56
|
+
* class LoginForm extends Form {
|
|
57
|
+
* email = "";
|
|
58
|
+
* password = "";
|
|
59
|
+
* rules(v: RuleBuilder) {
|
|
60
|
+
* return { email: v.string().email(), password: v.string().min(8) };
|
|
61
|
+
* }
|
|
62
|
+
* }
|
|
63
|
+
*
|
|
64
|
+
* class LoginPage extends Component {
|
|
65
|
+
* @expose form = new LoginForm();
|
|
66
|
+
*
|
|
67
|
+
* @expose async submit() {
|
|
68
|
+
* this.validate(this.form); // throws + re-renders with errors on failure
|
|
69
|
+
* await Auth.attempt(this.form.data());
|
|
70
|
+
* }
|
|
71
|
+
*
|
|
72
|
+
* override async render() {
|
|
73
|
+
* return (
|
|
74
|
+
* <form data-flow-root flow:submit="submit">
|
|
75
|
+
* <input value={this.form.email} />
|
|
76
|
+
* <input type="password" value={this.form.password} />
|
|
77
|
+
* <button>Log in</button>
|
|
78
|
+
* </form>
|
|
79
|
+
* );
|
|
80
|
+
* }
|
|
81
|
+
* }
|
|
82
|
+
* ```
|
|
83
|
+
*/
|
|
84
|
+
export abstract class Form {
|
|
85
|
+
/** Duck-type marker (avoids cross-module instanceof / import cycles). */
|
|
86
|
+
readonly __isFlowForm = true as const;
|
|
87
|
+
|
|
88
|
+
constructor() {
|
|
89
|
+
// Auto-register so the synth can reconstruct this subclass on hydration.
|
|
90
|
+
registerForm(this.constructor as new () => Form);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Validation schema, built with the @zerotal/validator RuleBuilder. Override in
|
|
95
|
+
* the subclass; fields are required by default.
|
|
96
|
+
*
|
|
97
|
+
* @example
|
|
98
|
+
* rules(v: RuleBuilder) {
|
|
99
|
+
* return { email: v.string().email(), age: v.number().min(18).optional() };
|
|
100
|
+
* }
|
|
101
|
+
*/
|
|
102
|
+
|
|
103
|
+
rules(_v: RuleBuilder): Record<string, FieldRule> {
|
|
104
|
+
return {};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The form's field values — own enumerable data props (excludes rules/methods/_*). */
|
|
108
|
+
data(): Record<string, unknown> {
|
|
109
|
+
const out: Record<string, unknown> = {};
|
|
110
|
+
for (const [k, v] of Object.entries(this)) {
|
|
111
|
+
if (_RESERVED.has(k) || k.startsWith("_") || typeof v === "function") continue;
|
|
112
|
+
out[k] = v;
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Assign matching fields from a plain object (unknown keys ignored). */
|
|
118
|
+
fill(values: Record<string, unknown>): this {
|
|
119
|
+
for (const [k, v] of Object.entries(values)) {
|
|
120
|
+
if (_RESERVED.has(k) || k.startsWith("_")) continue;
|
|
121
|
+
if (Object.prototype.hasOwnProperty.call(this, k)) {
|
|
122
|
+
(this as Record<string, unknown>)[k] = v;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return this;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Reset fields to their declared defaults — all of them, or just the named ones. */
|
|
129
|
+
reset(...fields: string[]): this {
|
|
130
|
+
const fresh = new (this.constructor as new () => this)();
|
|
131
|
+
const keys = fields.length ? fields : Object.keys(this.data());
|
|
132
|
+
for (const k of keys) {
|
|
133
|
+
(this as Record<string, unknown>)[k] = (fresh as Record<string, unknown>)[k];
|
|
134
|
+
}
|
|
135
|
+
return this;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* @internal Run the @zerotal/validator schema against the current data, returning
|
|
140
|
+
* a field → messages map (empty when valid). Used by `validate()` and by
|
|
141
|
+
* `Component.validate(form)` so both share the same engine + error shape.
|
|
142
|
+
*/
|
|
143
|
+
_validateToErrors(): Record<string, string[]> {
|
|
144
|
+
const factory = this.rules(new RuleBuilder());
|
|
145
|
+
const schema: Schema = {};
|
|
146
|
+
for (const [field, rule] of Object.entries(factory)) schema[field] = rule._def;
|
|
147
|
+
|
|
148
|
+
const result = runValidation(schema, this.data());
|
|
149
|
+
if (result.success) return {};
|
|
150
|
+
|
|
151
|
+
// Zerotal errors are one message per field; Flow's bag is field → messages[].
|
|
152
|
+
const out: Record<string, string[]> = {};
|
|
153
|
+
for (const [field, msg] of Object.entries(result.errors)) out[field] = [msg];
|
|
154
|
+
return out;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Validate the form against its {@link rules} (via `@zerotal/validator`).
|
|
159
|
+
*
|
|
160
|
+
* @returns the form's {@link data} on success.
|
|
161
|
+
* @throws {ValidationError} on failure — the framework catches it and re-renders with errors.
|
|
162
|
+
*/
|
|
163
|
+
validate(): Record<string, unknown> {
|
|
164
|
+
const errors = this._validateToErrors();
|
|
165
|
+
if (Object.keys(errors).length > 0) throw new ValidationError(errors);
|
|
166
|
+
return this.data();
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Serialize a Form to its field values + class name; rebuild the right subclass on hydrate.
|
|
171
|
+
registerSynth({
|
|
172
|
+
key: "form",
|
|
173
|
+
match: (v): v is Form =>
|
|
174
|
+
typeof v === "object" && v !== null && (v as { __isFlowForm?: unknown }).__isFlowForm === true,
|
|
175
|
+
dehydrate(form: Form, meta) {
|
|
176
|
+
meta["form"] = form.constructor.name;
|
|
177
|
+
return form.data();
|
|
178
|
+
},
|
|
179
|
+
hydrate(data, meta) {
|
|
180
|
+
const name = meta["form"] as string;
|
|
181
|
+
const FormClass = _formClasses.get(name);
|
|
182
|
+
if (!FormClass) {
|
|
183
|
+
throw new Error(
|
|
184
|
+
`[Flow] Unknown Form class "${name}". Ensure it is imported (instantiated at least once) on the server.`,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
return new FormClass().fill(data as Record<string, unknown>);
|
|
188
|
+
},
|
|
189
|
+
});
|
package/src/Layout.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { HtmlNode } from "./jsx-runtime.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Base class for Flow page layouts — persistent shell UI wrapped around page content.
|
|
5
|
+
*
|
|
6
|
+
* @remarks
|
|
7
|
+
* A layout wraps page content with shell UI (nav, sidebars, footers) that outlives page
|
|
8
|
+
* navigation. On `flow:navigate`, pages that share the same layout class only swap their
|
|
9
|
+
* `[data-flow-root]` content; the layout shell stays mounted and is never re-rendered or
|
|
10
|
+
* re-sent over the socket. Attach one with `static layout = AppLayout` on a {@link Component}
|
|
11
|
+
* (or override {@link Component.layout} for the JSX-native form).
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```tsx
|
|
15
|
+
* export class AppLayout extends Layout {
|
|
16
|
+
* static head = `<link rel="stylesheet" href="/app.css">`;
|
|
17
|
+
*
|
|
18
|
+
* render(slot: HtmlNode) {
|
|
19
|
+
* return (
|
|
20
|
+
* <div>
|
|
21
|
+
* <nav>
|
|
22
|
+
* <a href="/dashboard" flow:navigate>Dashboard</a>
|
|
23
|
+
* </nav>
|
|
24
|
+
* <main>{slot}</main>
|
|
25
|
+
* </div>
|
|
26
|
+
* );
|
|
27
|
+
* }
|
|
28
|
+
* }
|
|
29
|
+
*
|
|
30
|
+
* export class DashboardPage extends Component {
|
|
31
|
+
* static layout = AppLayout;
|
|
32
|
+
* }
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
export abstract class Layout {
|
|
36
|
+
/** Extra HTML injected into `<head>` (stylesheets, fonts, global meta tags). */
|
|
37
|
+
static head?: string;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Render the layout shell around a page.
|
|
41
|
+
*
|
|
42
|
+
* @param slot the page's pre-rendered `[data-flow-root]` component node. Place it with
|
|
43
|
+
* `{slot}` in JSX — its HTML is emitted verbatim without escaping.
|
|
44
|
+
* @returns the shell markup wrapping `slot`.
|
|
45
|
+
*/
|
|
46
|
+
abstract render(slot: HtmlNode): HtmlNode | Promise<HtmlNode>;
|
|
47
|
+
}
|
package/src/augment.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { PageClassWithMeta } from "./registry.ts";
|
|
2
|
+
import type { MiddlewareClass, RouteRegistration } from "@zerotal/core";
|
|
3
|
+
import type { FlowStore } from "./store.ts";
|
|
4
|
+
|
|
5
|
+
// Augment the core RouterMacros interface so Router.flow() is fully typed.
|
|
6
|
+
declare module "@zerotal/core" {
|
|
7
|
+
interface RouterMacros {
|
|
8
|
+
/**
|
|
9
|
+
* Register a Flow Component as a GET route.
|
|
10
|
+
*
|
|
11
|
+
* Middleware runs on the initial page load AND is re-applied on every
|
|
12
|
+
* WebSocket update (persistent middleware).
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* Router.flow('/dashboard', Dashboard);
|
|
16
|
+
* Router.flow('/admin', AdminPage, [RequireAuthMiddleware]);
|
|
17
|
+
*/
|
|
18
|
+
flow(
|
|
19
|
+
path: string,
|
|
20
|
+
PageClass: PageClassWithMeta,
|
|
21
|
+
middleware?: MiddlewareClass[],
|
|
22
|
+
): RouteRegistration;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* $flow — the client runtime object, available directly in JSX client expressions
|
|
28
|
+
* (event handlers, attribute bindings, text children) and `this.client()` scripts.
|
|
29
|
+
*
|
|
30
|
+
* It is the single home for all client-only magics, so the component class itself stays
|
|
31
|
+
* clean. The rule: `this.name` is always YOUR member (state/action); `$flow.name` is always a
|
|
32
|
+
* framework helper. You write the helpers BARE (`$flow.set`, `$flow.store`, `$flow.parent`) and
|
|
33
|
+
* the AOT compiler rewrites each to its canonical `$`-form (`$flow.$set`, …) for the runtime.
|
|
34
|
+
*
|
|
35
|
+
* $flow.set("open", true) write + sync an @expose property
|
|
36
|
+
* $flow.toggle("open") toggle + sync a boolean @expose property
|
|
37
|
+
* $flow.store.ui.dark the global client store (typed by FlowStore)
|
|
38
|
+
* $flow.parent.save() call an action / read state on the parent
|
|
39
|
+
* onClick={this.save} dispatch YOUR server action `save`
|
|
40
|
+
*/
|
|
41
|
+
declare global {
|
|
42
|
+
var $flow: {
|
|
43
|
+
// Reactive property reads/writes ($flow.count) and dynamic action calls. `any` because this
|
|
44
|
+
// object mixes dynamic members with the typed helpers below; the index signature can't be
|
|
45
|
+
// narrower than both.
|
|
46
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
47
|
+
[member: string]: any;
|
|
48
|
+
|
|
49
|
+
// Component identity (always $-prefixed — no bare form)
|
|
50
|
+
readonly $el: HTMLElement;
|
|
51
|
+
readonly $id: string;
|
|
52
|
+
readonly $name: string;
|
|
53
|
+
|
|
54
|
+
// Re-render
|
|
55
|
+
refresh(): void;
|
|
56
|
+
commit(): void;
|
|
57
|
+
|
|
58
|
+
// Property helpers
|
|
59
|
+
get(prop: string): unknown;
|
|
60
|
+
set(prop: string, value: unknown): void;
|
|
61
|
+
toggle(prop: string): void;
|
|
62
|
+
|
|
63
|
+
// Explicit method call
|
|
64
|
+
call(method: string, ...args: unknown[]): void;
|
|
65
|
+
|
|
66
|
+
// Cross-component events
|
|
67
|
+
dispatch(event: string, data?: Record<string, unknown>): void;
|
|
68
|
+
dispatchTo(component: string, event: string, data?: Record<string, unknown>): void;
|
|
69
|
+
dispatchSelf(event: string, data?: Record<string, unknown>): void;
|
|
70
|
+
on(event: string, fn: (detail: unknown) => void): void;
|
|
71
|
+
|
|
72
|
+
// Watch reactive property changes after server patches
|
|
73
|
+
watch(prop: string, fn: (newVal: unknown, oldVal: unknown) => void): void;
|
|
74
|
+
|
|
75
|
+
// Call actions on / read state from the parent component
|
|
76
|
+
readonly parent: Record<string, ((...args: unknown[]) => void) & unknown>;
|
|
77
|
+
|
|
78
|
+
// Global client store — app-wide, client-only reactive UI state (typed by FlowStore).
|
|
79
|
+
readonly store: FlowStore;
|
|
80
|
+
|
|
81
|
+
// Presence whispers (ephemeral peer state; never touches the server/DB)
|
|
82
|
+
whisper(event: string, data: unknown): void;
|
|
83
|
+
onWhisper(event: string, fn: (data: unknown) => void): void;
|
|
84
|
+
|
|
85
|
+
// Stop this component's running @task
|
|
86
|
+
cancel(): void;
|
|
87
|
+
|
|
88
|
+
// Optimistic list mutations (reconciled when the next action lands)
|
|
89
|
+
appendOptimistic<T>(prop: string, item: T): void;
|
|
90
|
+
removeOptimistic<T>(prop: string, match: (item: T) => boolean): void;
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export {};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Client-safe base error for Flow's browser runtime.
|
|
2
|
+
//
|
|
3
|
+
// This module is bundled into the CSP-safe client runtime, so it deliberately
|
|
4
|
+
// extends the native `Error` rather than `@zerotal/core`'s `ZerotalError`: pulling
|
|
5
|
+
// the server framework into the browser bundle is exactly what the CSP-safe runtime
|
|
6
|
+
// must avoid. Client-side Flow errors extend this class for a consistent base.
|
|
7
|
+
export class FlowClientError extends Error {
|
|
8
|
+
constructor(message: string) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = this.constructor.name;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// ── Client-side component state model ────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// Mirrors Livewire v3's three-copy state model:
|
|
4
|
+
// canonical — last confirmed server state (used for diffing)
|
|
5
|
+
// ephemeral — current local state (may have unsent mutations from flow:model)
|
|
6
|
+
// reactive — Alpine.reactive(ephemeral) — what DOM expressions read
|
|
7
|
+
//
|
|
8
|
+
// When a server patch arrives, we diff canonical vs. new to determine what
|
|
9
|
+
// changed and patch reactive in-place — preserving Alpine reactivity subscriptions
|
|
10
|
+
// instead of wholesale replacement.
|
|
11
|
+
|
|
12
|
+
import type { Snapshot, SnapshotData } from "../types.ts";
|
|
13
|
+
|
|
14
|
+
export type ReactiveProxy = Record<string, unknown>;
|
|
15
|
+
|
|
16
|
+
/** Extract plain JS values from snapshot data (strip synth meta). */
|
|
17
|
+
function extractValues(data: SnapshotData): Record<string, unknown> {
|
|
18
|
+
const result: Record<string, unknown> = {};
|
|
19
|
+
for (const [key, [val]] of Object.entries(data)) {
|
|
20
|
+
result[key] = val;
|
|
21
|
+
}
|
|
22
|
+
return result;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function deepClone<T>(v: T): T {
|
|
26
|
+
return JSON.parse(JSON.stringify(v)) as T;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Recursively diff old/new and patch reactive in-place. */
|
|
30
|
+
function diffAndPatch(
|
|
31
|
+
oldVal: Record<string, unknown>,
|
|
32
|
+
newVal: Record<string, unknown>,
|
|
33
|
+
reactive: Record<string, unknown>,
|
|
34
|
+
): void {
|
|
35
|
+
for (const [k, next] of Object.entries(newVal)) {
|
|
36
|
+
if (JSON.stringify(oldVal[k]) !== JSON.stringify(next)) {
|
|
37
|
+
// Deep-clone objects so that reactive mutations (from user input via flow:model)
|
|
38
|
+
// do not alias back into canonical/snapshot, which share the same references
|
|
39
|
+
// via extractValues. Without cloning, typing in a flow:model input would corrupt
|
|
40
|
+
// comp.snapshot.data causing the HMAC to fail on the next server call.
|
|
41
|
+
reactive[k] = typeof next === "object" && next !== null ? deepClone(next) : next;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// Remove keys that no longer exist
|
|
45
|
+
for (const k of Object.keys(oldVal)) {
|
|
46
|
+
if (!(k in newVal)) {
|
|
47
|
+
delete reactive[k];
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class FlowComponent {
|
|
53
|
+
readonly id: string;
|
|
54
|
+
readonly name: string;
|
|
55
|
+
readonly rootEl: HTMLElement;
|
|
56
|
+
|
|
57
|
+
canonical: Record<string, unknown>;
|
|
58
|
+
ephemeral: Record<string, unknown>;
|
|
59
|
+
reactive: ReactiveProxy;
|
|
60
|
+
|
|
61
|
+
snapshot: Snapshot;
|
|
62
|
+
|
|
63
|
+
constructor(rootEl: HTMLElement, snapshot: Snapshot) {
|
|
64
|
+
this.id = snapshot.memo.id;
|
|
65
|
+
this.name = snapshot.memo.name;
|
|
66
|
+
this.rootEl = rootEl;
|
|
67
|
+
this.snapshot = snapshot;
|
|
68
|
+
|
|
69
|
+
this.canonical = extractValues(snapshot.data);
|
|
70
|
+
this.ephemeral = deepClone(this.canonical);
|
|
71
|
+
|
|
72
|
+
// Alpine.reactive is available globally once Alpine starts
|
|
73
|
+
this.reactive = (
|
|
74
|
+
window as unknown as { Alpine: { reactive(o: object): ReactiveProxy } }
|
|
75
|
+
).Alpine.reactive(this.ephemeral);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Apply a server patch: update snapshot, diff-patch reactive, update script tag. */
|
|
79
|
+
mergeServerPatch(newSnapshot: Snapshot): void {
|
|
80
|
+
const newCanonical = extractValues(newSnapshot.data);
|
|
81
|
+
// Compare the current reactive state (not just canonical) so that lazy
|
|
82
|
+
// flow:model fields that diverged during typing are corrected when the
|
|
83
|
+
// server returns the authoritative value (e.g., clearing draft after add()).
|
|
84
|
+
// Using a plain-object snapshot avoids mutating the comparison source
|
|
85
|
+
// while diffAndPatch iterates it for key-removal.
|
|
86
|
+
const reactiveSnapshot = { ...this.reactive } as Record<string, unknown>;
|
|
87
|
+
diffAndPatch(reactiveSnapshot, newCanonical, this.reactive);
|
|
88
|
+
this.canonical = newCanonical;
|
|
89
|
+
this.ephemeral = deepClone(newCanonical);
|
|
90
|
+
this.snapshot = newSnapshot;
|
|
91
|
+
|
|
92
|
+
// Keep the embedded <script id="flow-state-*"> in sync
|
|
93
|
+
const script = document.getElementById(`flow-state-${this.id}`);
|
|
94
|
+
if (script) script.textContent = JSON.stringify(newSnapshot);
|
|
95
|
+
}
|
|
96
|
+
}
|