@effect-app/vue-components 4.0.0-beta.235 → 4.0.0-beta.236

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.
@@ -1,2 +1,21 @@
1
1
  import * as S from "effect-app/Schema";
2
2
  export declare const defaultsValueFromSchema: (schema: S.Schema<any>, record?: Record<string, any>) => any;
3
+ /**
4
+ * Deep-fills a partial form value with schema defaults for any nullable
5
+ * struct that has **materialised**.
6
+ *
7
+ * A nullable struct (`S.NullOr(S.Struct(...))`) left `null` stays `null`, but
8
+ * once it materialises — e.g. the user filled a single child — its untouched
9
+ * children are filled with their schema default (or `null` when nullable).
10
+ * Without this, strict `S.NullOr(...)` children reject the leftover
11
+ * `undefined` with a spurious "field must not be empty" error.
12
+ *
13
+ * Only children of a struct reached *through a nullable union* are filled;
14
+ * the always-present root struct keeps whatever fields it already has, so the
15
+ * form's own default-value priority is left untouched.
16
+ *
17
+ * Reference-preserving: returns `value` unchanged (same reference) when there
18
+ * is nothing to fill, so callers can detect a no-op with `===` and the result
19
+ * is idempotent (`fill(fill(v)) === fill(v)`).
20
+ */
21
+ export declare const fillNestedDefaults: (ast: S.AST.AST, value: unknown, fillMissing?: boolean) => unknown;
@@ -134,6 +134,12 @@ export interface OF<From, To> extends OmegaFormApi<From, To> {
134
134
  meta: MetaRecord<From>;
135
135
  unionMeta: Record<string, MetaRecord<From>>;
136
136
  clear: () => void;
137
+ /**
138
+ * Backfills the untouched children of any materialised nullable struct with
139
+ * their schema default (or `null`), so the live `values` matches what
140
+ * validation and decoding see. Called after a field change.
141
+ */
142
+ normalizeNullableStructs: () => void;
137
143
  i18nNamespace?: string;
138
144
  ignorePreventCloseEvents?: boolean;
139
145
  registerField: (field: ComputedRef<{
@@ -19,44 +19,96 @@ var a = (e) => {
19
19
  function s(e) {
20
20
  return e && "members" in e && Array.isArray(e.members);
21
21
  }
22
- var c = (r, i = {}) => {
23
- let a = r.ast, l = o(a);
24
- if (l !== void 0) return l;
22
+ var c = (e) => typeof e == "object" && !!e && !Array.isArray(e), l = (e) => n.AST.isNull(e) || n.AST.isUndefined(e), u = (e) => {
23
+ let r = t(e);
24
+ return n.AST.isUnion(r) ? r.types.flatMap(u) : [r];
25
+ }, d = (e) => {
26
+ let r = t(e);
27
+ if (n.AST.isLiteral(r)) return r.literal;
28
+ if (n.AST.isUnion(r) && r.types.length === 1) return d(r.types[0]);
29
+ }, f = (e, t) => {
30
+ if (!(!c(t) || t._tag === void 0)) return e.find((e) => {
31
+ let n = e.propertySignatures.find((e) => e.name.toString() === "_tag");
32
+ return n ? d(n.type) === t._tag : !1;
33
+ });
34
+ }, p = (r, i = {}) => {
35
+ let a = r.ast, c = o(a);
36
+ if (c !== void 0) return c;
25
37
  if (e(r.ast) === "null") return null;
26
38
  if (e(r.ast) === "undefined") return;
27
- let u = n.AST.isObjects(a) ? a : n.AST.isDeclaration(a) ? t(a) : void 0;
28
- if (u && n.AST.isObjects(u)) {
39
+ let l = n.AST.isObjects(a) ? a : n.AST.isDeclaration(a) ? t(a) : void 0;
40
+ if (l && n.AST.isObjects(l)) {
29
41
  let t = {};
30
- for (let r of u.propertySignatures) {
31
- let a = r.name.toString(), s = r.type, l = o(s);
32
- if (l !== void 0) {
33
- t[a] = l;
42
+ for (let r of l.propertySignatures) {
43
+ let a = r.name.toString(), s = r.type, c = o(s);
44
+ if (c !== void 0) {
45
+ t[a] = c;
34
46
  continue;
35
47
  }
36
- let u = c(n.make(s), i[a] || {});
37
- u === void 0 ? e(s) === "undefined" && (t[a] = void 0) : t[a] = u;
48
+ let l = p(n.make(s), i[a] || {});
49
+ l === void 0 ? e(s) === "undefined" && (t[a] = void 0) : t[a] = l;
38
50
  }
39
51
  return {
40
52
  ...t,
41
53
  ...i
42
54
  };
43
55
  }
44
- let d = n.AST.isUnion(a) ? a.types : s(r) ? r.members.map((e) => e.ast) : void 0;
45
- if (d) {
56
+ let u = n.AST.isUnion(a) ? a.types : s(r) ? r.members.map((e) => e.ast) : void 0;
57
+ if (u) {
46
58
  let e = {};
47
- for (let r of d) {
59
+ for (let r of u) {
48
60
  let i = t(r);
49
61
  if (n.AST.isObjects(i)) for (let t of i.propertySignatures) {
50
62
  let n = t.name.toString(), r = o(t.type), i = e[n] ? o(e[n].ast) : void 0;
51
63
  (!e[n] || r !== void 0 && i === void 0) && (e[n] = { ast: t.type });
52
64
  }
53
65
  }
54
- return Object.keys(e).length === 0 ? Object.keys(i).length > 0 ? i : void 0 : Object.entries(e).reduce((e, [t, { ast: r }]) => (e[t] = c(n.make(r), i[t] || {}), e), i);
66
+ return Object.keys(e).length === 0 ? Object.keys(i).length > 0 ? i : void 0 : Object.entries(e).reduce((e, [t, { ast: r }]) => (e[t] = p(n.make(r), i[t] || {}), e), i);
55
67
  }
56
68
  if (Object.keys(i).length === 0) {
57
69
  if (n.AST.isString(a)) return "";
58
70
  if (n.AST.isBoolean(a)) return !1;
59
71
  }
72
+ }, m = (r, i, a = !1) => {
73
+ let s = t(r);
74
+ switch (s._tag) {
75
+ case "Union": {
76
+ if (i == null) return i;
77
+ let e = u(s), t = e.filter(n.AST.isObjects);
78
+ if (t.length === 0) return i;
79
+ let r = e.some(l), o = f(t, i);
80
+ return o ? m(o, i, a || r) : r && t.length === 1 ? m(t[0], i, !0) : i;
81
+ }
82
+ case "Arrays": {
83
+ if (!Array.isArray(i)) return i;
84
+ let e = s.rest[0];
85
+ if (!e) return i;
86
+ let t = !1, n = i.map((n) => {
87
+ let r = m(e, n, a);
88
+ return r !== n && (t = !0), r;
89
+ });
90
+ return t ? n : i;
91
+ }
92
+ case "Objects": {
93
+ if (!c(i)) return i;
94
+ let t = i, n = !1, r = (e, r) => {
95
+ n ||= (t = { ...i }, !0), t[e] = r;
96
+ };
97
+ for (let t of s.propertySignatures) {
98
+ let n = t.name.toString(), s = i[n];
99
+ if (s !== void 0) {
100
+ let e = m(t.type, s, a);
101
+ e !== s && r(n, e);
102
+ continue;
103
+ }
104
+ if (!a || t.type.context?.isOptional === !0) continue;
105
+ let c = o(t.type);
106
+ c === void 0 ? e(t.type) === "null" && r(n, null) : r(n, c);
107
+ }
108
+ return t;
109
+ }
110
+ default: return i;
111
+ }
60
112
  };
61
113
  //#endregion
62
- export { c as defaultsValueFromSchema };
114
+ export { p as defaultsValueFromSchema, m as fillNestedDefaults };
@@ -33,7 +33,7 @@ var b = /* @__PURE__ */ o({
33
33
  id: D
34
34
  })));
35
35
  let j = t(() => k.value[b.field.name]?.errors ?? []), M = t(() => j.value.map((e) => e?.message).filter(Boolean)), N = (e) => e == null || e === !1 || e === "" || Number.isNaN(e), P = (e) => {
36
- N(e) && b.meta?.type !== "boolean" ? b.meta?.nullableOrUndefined ? b.field.handleChange(b.meta.nullableOrUndefined === "undefined" ? void 0 : null) : b.meta?.isOptionalKey ? b.field.form.deleteField(b.field.name) : b.field.handleChange(e) : b.field.handleChange(e);
36
+ N(e) && b.meta?.type !== "boolean" ? b.meta?.nullableOrUndefined ? b.field.handleChange(b.meta.nullableOrUndefined === "undefined" ? void 0 : null) : b.meta?.isOptionalKey ? b.field.form.deleteField(b.field.name) : b.field.handleChange(e) : b.field.handleChange(e), b.field.form.normalizeNullableStructs?.();
37
37
  }, F = (e) => new Proxy(e, { get(e, t, n) {
38
38
  return t === "handleChange" ? P : Reflect.get(...arguments);
39
39
  } }), I = t(() => ({
@@ -1,82 +1,100 @@
1
1
  import { useIntl as e } from "./vue-components6.es.js";
2
- import { generateMetaFromSchema as t } from "./vue-components14.es.js";
3
- import { defaultsValueFromSchema as n } from "./vue-components15.es.js";
4
- import { toFormSchema as r } from "./vue-components16.es.js";
5
- import { usePersistency as i } from "./vue-components18.es.js";
6
- import { annotateLiteralUnionMessages as a, toLocalizedStandardSchemaV1 as o } from "./vue-components19.es.js";
7
- import { eHoc as s, makeFieldMap as c } from "./vue-components20.es.js";
8
- import { fHoc as l } from "./vue-components21.es.js";
9
- import u from "./vue-components23.es.js";
10
- import d from "./vue-components25.es.js";
11
- import f from "./vue-components29.es.js";
12
- import p from "./vue-components36.es.js";
13
- import m from "./vue-components40.es.js";
14
- import h from "./vue-components44.es.js";
15
- import { makeSubmitHandlers as g, wrapOnSubmit as _ } from "./vue-components67.es.js";
16
- import { watch as v } from "vue";
17
- import * as y from "effect-app/Schema";
18
- import { revalidateLogic as b, useForm as x } from "@tanstack/vue-form";
19
- import * as S from "effect-app/Context";
20
- import { makeRunPromise as C } from "@effect-app/vue/runtime";
2
+ import { generateMetaFromSchema as t, unwrapDeclaration as n } from "./vue-components14.es.js";
3
+ import { defaultsValueFromSchema as r, fillNestedDefaults as i } from "./vue-components15.es.js";
4
+ import { toFormSchema as a } from "./vue-components16.es.js";
5
+ import { usePersistency as o } from "./vue-components18.es.js";
6
+ import { annotateLiteralUnionMessages as s, toLocalizedStandardSchemaV1 as c } from "./vue-components19.es.js";
7
+ import { eHoc as l, makeFieldMap as u } from "./vue-components20.es.js";
8
+ import { fHoc as d } from "./vue-components21.es.js";
9
+ import f from "./vue-components23.es.js";
10
+ import p from "./vue-components25.es.js";
11
+ import m from "./vue-components29.es.js";
12
+ import h from "./vue-components36.es.js";
13
+ import g from "./vue-components40.es.js";
14
+ import _ from "./vue-components44.es.js";
15
+ import { makeSubmitHandlers as v, wrapOnSubmit as y } from "./vue-components67.es.js";
16
+ import { watch as b } from "vue";
17
+ import * as x from "effect-app/Schema";
18
+ import { revalidateLogic as S, useForm as C } from "@tanstack/vue-form";
19
+ import * as w from "effect-app/Context";
20
+ import { makeRunPromise as T } from "@effect-app/vue/runtime";
21
21
  //#region src/components/OmegaForm/useOmegaForm.ts
22
- var w = Symbol("OmegaForm"), T = C(S.empty()), E = (S, C, w) => {
23
- if (!S) throw Error("Schema is required");
24
- let { trans: E } = e(), D = r(S), O = o(a(D, E), E), k = y.decodeUnknownEffectConcurrently(D), { meta: A, unionMeta: j } = t(D), M = { form: void 0 }, N = i({
25
- meta: A,
26
- persistency: w?.persistency,
27
- preventWindowExit: w?.preventWindowExit,
28
- defaultValuesPriority: w?.defaultValuesPriority,
29
- tanstackDefaultValues: C?.defaultValues,
30
- schemaDefaultValues: () => n(S),
31
- getForm: () => M.form
32
- }), P = x({
33
- ...C,
34
- validationLogic: b(),
22
+ var E = Symbol("OmegaForm"), D = T(w.empty()), O = (w, T, E) => {
23
+ if (!w) throw Error("Schema is required");
24
+ let { trans: O } = e(), k = a(w), A = s(k, O), j = n(k.ast), M = (e) => i(j, e), N = c(A, O), P = {
25
+ ...N,
26
+ "~standard": {
27
+ ...N["~standard"],
28
+ validate: (e) => N["~standard"].validate(M(e))
29
+ }
30
+ }, F = x.decodeUnknownEffectConcurrently(k), I = (e) => F(M(e)), { meta: L, unionMeta: R } = t(k), z = { form: void 0 }, B = o({
31
+ meta: L,
32
+ persistency: E?.persistency,
33
+ preventWindowExit: E?.preventWindowExit,
34
+ defaultValuesPriority: E?.defaultValuesPriority,
35
+ tanstackDefaultValues: T?.defaultValues,
36
+ schemaDefaultValues: () => r(w),
37
+ getForm: () => z.form
38
+ }), V = C({
39
+ ...T,
40
+ validationLogic: S(),
35
41
  validators: {
36
- onDynamic: O,
37
- ...C?.validators
42
+ onDynamic: P,
43
+ ...T?.validators
38
44
  },
39
- onSubmit: _(C?.onSubmit, k, T),
40
- defaultValues: N.defaultValues.value
45
+ onSubmit: y(T?.onSubmit, I, D),
46
+ defaultValues: B.defaultValues.value
41
47
  });
42
- M.form = P;
43
- let F = () => {
44
- Object.keys(A).forEach((e) => {
45
- P.setFieldValue(e, void 0);
48
+ z.form = V;
49
+ let H = () => {
50
+ let e = V.state.values, t = i(j, e);
51
+ if (t === e || !t || typeof t != "object") return;
52
+ let n = e, r = t;
53
+ for (let e of Object.keys(r)) if (r[e] !== n[e]) {
54
+ let t = e;
55
+ V.setFieldValue(t, r[e], {
56
+ dontUpdateMeta: !0,
57
+ dontValidate: !0
58
+ });
59
+ }
60
+ }, U = () => {
61
+ Object.keys(L).forEach((e) => {
62
+ V.setFieldValue(e, void 0);
46
63
  });
47
64
  };
48
- if (w?.preventWindowExit === "prevent-and-reset") {
49
- let e = P.useStore((e) => e.isSubmitting), t = P.useStore((e) => e.submissionAttempts), n = P.useStore((e) => e.canSubmit), r = P.useStore((e) => e.values);
50
- v([e, t], ([e, t], [i]) => {
51
- i && !e && t > 0 && n.value && P.reset(r.value);
65
+ if (E?.preventWindowExit === "prevent-and-reset") {
66
+ let e = V.useStore((e) => e.isSubmitting), t = V.useStore((e) => e.submissionAttempts), n = V.useStore((e) => e.canSubmit), r = V.useStore((e) => e.values);
67
+ b([e, t], ([e, t], [i]) => {
68
+ i && !e && t > 0 && n.value && V.reset(r.value);
52
69
  });
53
70
  }
54
- let { handleSubmit: I, handleSubmitEffect: L } = g(P), { fieldMap: R, registerField: z } = c(), B = Object.assign(P, {
55
- i18nNamespace: w?.i18nNamespace,
56
- ignorePreventCloseEvents: w?.ignorePreventCloseEvents,
57
- meta: A,
58
- unionMeta: j,
59
- clear: F,
60
- handleSubmit: I,
61
- handleSubmitEffect: L,
62
- registerField: z
63
- }), V = {
64
- form: B,
65
- fieldMap: R
71
+ let { handleSubmit: W, handleSubmitEffect: G } = v(V), { fieldMap: K, registerField: q } = u(), J = Object.assign(V, {
72
+ i18nNamespace: E?.i18nNamespace,
73
+ ignorePreventCloseEvents: E?.ignorePreventCloseEvents,
74
+ meta: L,
75
+ unionMeta: R,
76
+ clear: U,
77
+ normalizeNullableStructs: H,
78
+ handleSubmit: W,
79
+ handleSubmitEffect: G,
80
+ registerField: q
81
+ }), Y = {
82
+ form: J,
83
+ fieldMap: K
66
84
  };
67
- return Object.assign(B, {
85
+ return Object.assign(J, {
68
86
  _paths: void 0,
69
87
  _keys: void 0,
70
- _schema: S,
71
- errorContext: V,
72
- Form: l(B)(h),
73
- Input: l(B)(w?.input ?? p),
74
- TaggedUnion: l(B)(m),
75
- Field: P.Field,
76
- Errors: s(V)(f),
77
- Array: l(B)(u),
78
- AutoGen: l(B)(d)
88
+ _schema: w,
89
+ errorContext: Y,
90
+ Form: d(J)(_),
91
+ Input: d(J)(E?.input ?? h),
92
+ TaggedUnion: d(J)(g),
93
+ Field: V.Field,
94
+ Errors: l(Y)(m),
95
+ Array: d(J)(f),
96
+ AutoGen: d(J)(p)
79
97
  });
80
98
  };
81
99
  //#endregion
82
- export { w as OmegaFormKey, E as useOmegaForm };
100
+ export { E as OmegaFormKey, O as useOmegaForm };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effect-app/vue-components",
3
- "version": "4.0.0-beta.235",
3
+ "version": "4.0.0-beta.236",
4
4
  "peerDependencies": {
5
5
  "@mdi/js": "^7.4.47",
6
6
  "effect": "^4.0.0-beta.68",
@@ -57,8 +57,8 @@
57
57
  "highlight.js": "^11.11.1",
58
58
  "mitt": "^3.0.1",
59
59
  "vue3-highlightjs": "^1.0.5",
60
- "@effect-app/vue": "4.0.0-beta.235",
61
- "effect-app": "4.0.0-beta.235"
60
+ "@effect-app/vue": "4.0.0-beta.236",
61
+ "effect-app": "4.0.0-beta.236"
62
62
  },
63
63
  "scripts": {
64
64
  "check": "vue-tsc",
@@ -145,6 +145,12 @@ const handleChange: OmegaFieldInternalApi<From, Name>["handleChange"] = (value)
145
145
  } else {
146
146
  props.field.handleChange(value)
147
147
  }
148
+
149
+ // A change here may have materialised a nullable struct (this field is one
150
+ // of its children); backfill the untouched siblings so the live `values`
151
+ // matches what validation and decoding see.
152
+ const form = props.field.form as { normalizeNullableStructs?: () => void }
153
+ form.normalizeNullableStructs?.()
148
154
  }
149
155
 
150
156
  // Note: Default value normalization (converting empty strings to null/undefined for nullable fields)
@@ -33,10 +33,44 @@ type SchemaWithMembers = {
33
33
  members: readonly S.Schema<any>[]
34
34
  }
35
35
 
36
+ type UnknownRecord = Record<string, unknown>
37
+
36
38
  function hasMembers(schema: any): schema is SchemaWithMembers {
37
39
  return schema && "members" in schema && Array.isArray(schema.members)
38
40
  }
39
41
 
42
+ const isRecord = (value: unknown): value is UnknownRecord =>
43
+ value !== null && typeof value === "object" && !Array.isArray(value)
44
+
45
+ const isNullishAst = (ast: S.AST.AST) => S.AST.isNull(ast) || S.AST.isUndefined(ast)
46
+
47
+ const unionMembers = (ast: S.AST.AST): readonly S.AST.AST[] => {
48
+ const resolved = unwrapDeclaration(ast)
49
+ return S.AST.isUnion(resolved)
50
+ ? resolved.types.flatMap(unionMembers)
51
+ : [resolved]
52
+ }
53
+
54
+ const literalValue = (ast: S.AST.AST): unknown => {
55
+ const resolved = unwrapDeclaration(ast)
56
+ if (S.AST.isLiteral(resolved)) return resolved.literal
57
+ if (S.AST.isUnion(resolved) && resolved.types.length === 1) {
58
+ return literalValue(resolved.types[0])
59
+ }
60
+ }
61
+
62
+ const findTaggedObjectMember = (
63
+ members: readonly S.AST.Objects[],
64
+ value: unknown
65
+ ): S.AST.Objects | undefined => {
66
+ if (!isRecord(value) || value._tag === undefined) return undefined
67
+
68
+ return members.find((member) => {
69
+ const tagProp = member.propertySignatures.find((prop) => prop.name.toString() === "_tag")
70
+ return tagProp ? literalValue(tagProp.type) === value._tag : false
71
+ })
72
+ }
73
+
40
74
  // Internal implementation with WeakSet tracking
41
75
  export const defaultsValueFromSchema = (
42
76
  schema: S.Schema<any>,
@@ -132,3 +166,96 @@ export const defaultsValueFromSchema = (
132
166
  }
133
167
  }
134
168
  }
169
+
170
+ /**
171
+ * Deep-fills a partial form value with schema defaults for any nullable
172
+ * struct that has **materialised**.
173
+ *
174
+ * A nullable struct (`S.NullOr(S.Struct(...))`) left `null` stays `null`, but
175
+ * once it materialises — e.g. the user filled a single child — its untouched
176
+ * children are filled with their schema default (or `null` when nullable).
177
+ * Without this, strict `S.NullOr(...)` children reject the leftover
178
+ * `undefined` with a spurious "field must not be empty" error.
179
+ *
180
+ * Only children of a struct reached *through a nullable union* are filled;
181
+ * the always-present root struct keeps whatever fields it already has, so the
182
+ * form's own default-value priority is left untouched.
183
+ *
184
+ * Reference-preserving: returns `value` unchanged (same reference) when there
185
+ * is nothing to fill, so callers can detect a no-op with `===` and the result
186
+ * is idempotent (`fill(fill(v)) === fill(v)`).
187
+ */
188
+ export const fillNestedDefaults = (
189
+ ast: S.AST.AST,
190
+ value: unknown,
191
+ fillMissing = false
192
+ ): unknown => {
193
+ const resolved = unwrapDeclaration(ast)
194
+
195
+ switch (resolved._tag) {
196
+ case "Union": {
197
+ if (value === null || value === undefined) return value
198
+ const members = unionMembers(resolved)
199
+ const objectMembers = members.filter(S.AST.isObjects)
200
+ if (objectMembers.length === 0) return value
201
+
202
+ const hasNullishMember = members.some(isNullishAst)
203
+ const taggedMember = findTaggedObjectMember(objectMembers, value)
204
+ if (taggedMember) {
205
+ return fillNestedDefaults(taggedMember, value, fillMissing || hasNullishMember)
206
+ }
207
+
208
+ if (hasNullishMember && objectMembers.length === 1) {
209
+ return fillNestedDefaults(objectMembers[0], value, true)
210
+ }
211
+
212
+ return value
213
+ }
214
+
215
+ case "Arrays": {
216
+ if (!Array.isArray(value)) return value
217
+ const element = resolved.rest[0]
218
+ if (!element) return value
219
+ let changed = false
220
+ const next = value.map((item) => {
221
+ const filled = fillNestedDefaults(element, item, fillMissing)
222
+ if (filled !== item) changed = true
223
+ return filled
224
+ })
225
+ return changed ? next : value
226
+ }
227
+
228
+ case "Objects": {
229
+ if (!isRecord(value)) return value
230
+ let result: UnknownRecord = value
231
+ let changed = false
232
+ const set = (key: string, next: unknown) => {
233
+ if (!changed) {
234
+ result = { ...value }
235
+ changed = true
236
+ }
237
+ result[key] = next
238
+ }
239
+ for (const prop of resolved.propertySignatures) {
240
+ const key = prop.name.toString()
241
+ const current = value[key]
242
+ if (current !== undefined) {
243
+ const filled = fillNestedDefaults(prop.type, current, fillMissing)
244
+ if (filled !== current) set(key, filled)
245
+ continue
246
+ }
247
+ if (!fillMissing || prop.type.context?.isOptional === true) continue
248
+ const propDefault = getDefaultFromAst(prop.type)
249
+ if (propDefault !== undefined) {
250
+ set(key, propDefault)
251
+ } else if (isNullableOrUndefined(prop.type) === "null") {
252
+ set(key, null)
253
+ }
254
+ }
255
+ return result
256
+ }
257
+
258
+ default:
259
+ return value
260
+ }
261
+ }
@@ -295,6 +295,12 @@ export interface OF<From, To> extends OmegaFormApi<From, To> {
295
295
  meta: MetaRecord<From>
296
296
  unionMeta: Record<string, MetaRecord<From>>
297
297
  clear: () => void
298
+ /**
299
+ * Backfills the untouched children of any materialised nullable struct with
300
+ * their schema default (or `null`), so the live `values` matches what
301
+ * validation and decoding see. Called after a field change.
302
+ */
303
+ normalizeNullableStructs: () => void
298
304
  i18nNamespace?: string
299
305
  ignorePreventCloseEvents?: boolean
300
306
  registerField: (
@@ -1,14 +1,14 @@
1
1
  /* eslint-disable @typescript-eslint/consistent-type-imports */
2
2
  /* eslint-disable @typescript-eslint/no-explicit-any */
3
3
 
4
- import { type FormAsyncValidateOrFn, type FormValidateOrFn, revalidateLogic, type StandardSchemaV1, useForm } from "@tanstack/vue-form"
4
+ import { type DeepKeys, type DeepValue, type FormAsyncValidateOrFn, type FormValidateOrFn, revalidateLogic, type StandardSchemaV1, useForm } from "@tanstack/vue-form"
5
5
  import * as Context from "effect-app/Context"
6
6
  import * as S from "effect-app/Schema"
7
7
  import { type InjectionKey, watch } from "vue"
8
8
  import { eHoc, makeFieldMap } from "./errors"
9
9
  import { fHoc } from "./hocs"
10
- import { generateMetaFromSchema } from "./meta/createMeta"
11
- import { defaultsValueFromSchema } from "./meta/defaults"
10
+ import { generateMetaFromSchema, unwrapDeclaration } from "./meta/createMeta"
11
+ import { defaultsValueFromSchema, fillNestedDefaults } from "./meta/defaults"
12
12
  import { toFormSchema } from "./meta/redacted"
13
13
  import OmegaArray from "./OmegaArray.vue"
14
14
  import OmegaAutoGen from "./OmegaAutoGen.vue"
@@ -51,11 +51,29 @@ export const useOmegaForm = <
51
51
  // (select) and literal-array (multiple) AST nodes with a localized
52
52
  // `message` so the formatter picks them up via `findMessage`.
53
53
  const localizedSchema = annotateLiteralUnionMessages(formCompatibleSchema, trans)
54
- const standardSchema = toLocalizedStandardSchemaV1(
54
+
55
+ // A nullable struct (`S.NullOr(S.Struct(...))`) has no slot in the form
56
+ // value until a child is filled. Once it materialises, its untouched
57
+ // children are still `undefined` — which strict `S.NullOr(...)` children
58
+ // reject. Deep-fill those children with their defaults before both
59
+ // validation and decoding so they validate as `null` (or their default).
60
+ const formAst = unwrapDeclaration(formCompatibleSchema.ast)
61
+ const normalizeFormValue = (value: unknown) => fillNestedDefaults(formAst, value)
62
+
63
+ const baseStandardSchema = toLocalizedStandardSchemaV1(
55
64
  localizedSchema as any,
56
65
  trans
57
66
  )
58
- const decode = S.decodeUnknownEffectConcurrently(formCompatibleSchema)
67
+ const standardSchema: typeof baseStandardSchema = {
68
+ ...baseStandardSchema,
69
+ "~standard": {
70
+ ...baseStandardSchema["~standard"],
71
+ validate: (value) => baseStandardSchema["~standard"].validate(normalizeFormValue(value))
72
+ }
73
+ }
74
+
75
+ const baseDecode = S.decodeUnknownEffectConcurrently(formCompatibleSchema)
76
+ const decode = (value: From) => baseDecode(normalizeFormValue(value))
59
77
 
60
78
  const { meta, unionMeta } = generateMetaFromSchema(formCompatibleSchema)
61
79
 
@@ -100,6 +118,27 @@ export const useOmegaForm = <
100
118
  }) satisfies OmegaFormApi<To, From>
101
119
  formHolder.form = form
102
120
 
121
+ // Keep the live form state consistent with the schema: when a nullable
122
+ // struct materialises (a child got a value), backfill its untouched
123
+ // children so `values` reflects what is validated and submitted. Called
124
+ // from the field change handler — the only point a struct can materialise.
125
+ const normalizeNullableStructs = () => {
126
+ const current = form.state.values
127
+ const filled = fillNestedDefaults(formAst, current)
128
+ if (filled === current || !filled || typeof filled !== "object") return
129
+ const currentRecord = current as Partial<Record<Extract<keyof From, string>, unknown>>
130
+ const filledRecord = filled as Partial<Record<Extract<keyof From, string>, unknown>>
131
+ for (const key of Object.keys(filledRecord) as Array<Extract<keyof From, string>>) {
132
+ if (filledRecord[key] !== currentRecord[key]) {
133
+ const field = key as DeepKeys<From>
134
+ form.setFieldValue(field, filledRecord[key] as DeepValue<From, typeof field>, {
135
+ dontUpdateMeta: true,
136
+ dontValidate: true
137
+ })
138
+ }
139
+ }
140
+ }
141
+
103
142
  const clear = () => {
104
143
  Object.keys(meta).forEach((key: any) => {
105
144
  form.setFieldValue(key, undefined as any)
@@ -134,6 +173,7 @@ export const useOmegaForm = <
134
173
  meta,
135
174
  unionMeta,
136
175
  clear,
176
+ normalizeNullableStructs,
137
177
  handleSubmit,
138
178
  // /** @experimental */
139
179
  handleSubmitEffect,