@cosmicdrift/kumiko-headless 0.186.0 → 0.186.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-headless",
3
- "version": "0.186.0",
3
+ "version": "0.186.2",
4
4
  "description": "Headless UI logic for Kumiko — Dispatcher contract, Form-Controller, View-Model, Nav-Resolver. Plattform- und React-frei; jeder Renderer (renderer, renderer-web, renderer-native, …) komponiert darauf.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -36,7 +36,7 @@
36
36
  }
37
37
  },
38
38
  "dependencies": {
39
- "@cosmicdrift/kumiko-framework": "0.186.0",
39
+ "@cosmicdrift/kumiko-framework": "0.186.2",
40
40
  "temporal-polyfill": "^0.3.2",
41
41
  "zod": "^4.4.3"
42
42
  },
@@ -147,6 +147,39 @@ describe("createFormController — submit()", () => {
147
147
  expect(disp.writeSpy).toHaveBeenCalledWith("app:write:task:update", { title: "world" });
148
148
  });
149
149
 
150
+ test("payloadMode: 'values' — strips an untouched optional field seeded with \"\"", async () => {
151
+ // `dueDate` was never touched by the user; buildInitialValues seeded it
152
+ // with "" for the controlled input. A `.optional()` server schema
153
+ // rejects "" but accepts a missing key, so the key must be dropped
154
+ // entirely — not sent as `dueDate: undefined`.
155
+ const disp = makeDispatcher();
156
+ const form = createFormController({
157
+ initial: { title: "hello", dueDate: "" },
158
+ submit: { dispatcher: disp, type: "app:write:task:create" },
159
+ });
160
+
161
+ await form.submit();
162
+
163
+ const call = disp.writeSpy.mock.calls[0]?.[1] as Record<string, unknown>;
164
+ expect("dueDate" in call).toBe(false);
165
+ expect(call["title"]).toBe("hello");
166
+ });
167
+
168
+ test("payloadMode: 'values' — keeps an explicitly-cleared field that started non-empty", async () => {
169
+ // `note` had a real value initially; the user cleared it via setField.
170
+ // That's a genuine change, not an untouched seed — it must survive.
171
+ const disp = makeDispatcher();
172
+ const form = createFormController({
173
+ initial: { note: "x" },
174
+ submit: { dispatcher: disp, type: "app:write:task:create" },
175
+ });
176
+ form.setField("note", "");
177
+
178
+ await form.submit();
179
+
180
+ expect(disp.writeSpy).toHaveBeenCalledWith("app:write:task:create", { note: "" });
181
+ });
182
+
150
183
  test("stale-submit race: edits during the in-flight write stay dirty after success", async () => {
151
184
  // User submits "hello", the network takes 50ms. During those 50ms the
152
185
  // user types "world" into the same field. The server sees "hello"
@@ -74,6 +74,24 @@ function valuesDiff<TValues extends FormValues>(
74
74
  return out;
75
75
  }
76
76
 
77
+ // buildInitialValues seeds untouched optional fields with "" for controlled
78
+ // inputs; a `.optional()` server schema accepts undefined but not "".
79
+ function stripUntouchedEmptyStrings<TValues extends FormValues>(
80
+ values: TValues,
81
+ initial: TValues,
82
+ ): TValues {
83
+ const v = values as Record<string, unknown>; // @cast-boundary form-values
84
+ const ini = initial as Record<string, unknown>; // @cast-boundary form-values
85
+ let out: Record<string, unknown> | undefined;
86
+ for (const key of Object.keys(v)) {
87
+ if (v[key] === "" && Object.is(v[key], ini[key])) {
88
+ out ??= { ...v };
89
+ delete out[key];
90
+ }
91
+ }
92
+ return (out ?? v) as TValues;
93
+ }
94
+
77
95
  // Shallow-freeze so accidental mutations on the snapshot (e.g. a test
78
96
  // writing `snapshot.values.title = "x"`) throw in strict mode instead of
79
97
  // silently diverging from the controller's internal state. Deep-freeze
@@ -304,7 +322,7 @@ export function createFormController<TValues extends FormValues, TCtx = unknown>
304
322
  }
305
323
  payload = submittedSnapshot.changes;
306
324
  } else {
307
- payload = submittedValues;
325
+ payload = stripUntouchedEmptyStrings(submittedValues, submittedSnapshot.initial);
308
326
  }
309
327
 
310
328
  const runWrite = async (): Promise<SubmitResult<TData>> => {