@cosmicdrift/kumiko-renderer 0.262.0 → 0.264.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer",
3
- "version": "0.262.0",
3
+ "version": "0.264.0",
4
4
  "description": "Platform-agnostic React renderer for Kumiko screens. Contains the shared logic — primitives-contract, hooks, KumikoScreen, navigation & SSE abstractions — that any platform-specific renderer (web, native) composes. No DOM, no EventSource, no react-dom.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -15,8 +15,8 @@
15
15
  }
16
16
  },
17
17
  "dependencies": {
18
- "@cosmicdrift/kumiko-framework": "0.262.0",
19
- "@cosmicdrift/kumiko-headless": "0.262.0",
18
+ "@cosmicdrift/kumiko-framework": "0.264.0",
19
+ "@cosmicdrift/kumiko-headless": "0.264.0",
20
20
  "react": "^19.2.6",
21
21
  "temporal-polyfill": "^0.3.2",
22
22
  "zod": "^4.4.3"
@@ -27,7 +27,7 @@
27
27
  "@types/react-dom": "^19.2.3",
28
28
  "jsdom": "^29.1.1",
29
29
  "react-dom": "^19.2.6",
30
- "@cosmicdrift/kumiko-locale-de": "0.262.0"
30
+ "@cosmicdrift/kumiko-locale-de": "0.264.0"
31
31
  },
32
32
  "repository": {
33
33
  "type": "git",
@@ -12,34 +12,133 @@ type FieldDef = {
12
12
  maxItems?: number;
13
13
  };
14
14
 
15
- describe("mergeSearchParamsIntoInitial", () => {
15
+ function mergeWithAllFieldsUrlPrefillable(
16
+ fields: Record<string, FieldDef>,
17
+ searchParams: Record<string, string>,
18
+ renderableFields?: ReadonlySet<string>,
19
+ defaultCurrency?: string,
20
+ ): Record<string, unknown> {
21
+ return mergeSearchParamsIntoInitial(fields, {
22
+ searchParams,
23
+ urlPrefillFields: Object.keys(fields),
24
+ ...(renderableFields !== undefined && { renderableFields }),
25
+ ...(defaultCurrency !== undefined && { defaultCurrency }),
26
+ });
27
+ }
28
+
29
+ describe("mergeSearchParamsIntoInitial — urlPrefillFields allowlist", () => {
30
+ const fields: Record<string, FieldDef> = {
31
+ leaseId: { type: "text" },
32
+ iban: { type: "text", default: "DE00" },
33
+ };
34
+
35
+ test("a URL param for a declared field prefills it", () => {
36
+ const result = mergeSearchParamsIntoInitial(fields, {
37
+ searchParams: { leaseId: "l-1" },
38
+ urlPrefillFields: ["leaseId"],
39
+ });
40
+ expect(result["leaseId"]).toBe("l-1");
41
+ });
42
+
43
+ test("a crafted link cannot prefill a field no navigate params declares", () => {
44
+ const result = mergeSearchParamsIntoInitial(fields, {
45
+ searchParams: { leaseId: "l-1", iban: "attacker-iban" },
46
+ urlPrefillFields: ["leaseId"],
47
+ });
48
+ expect(result["leaseId"]).toBe("l-1");
49
+ expect(result["iban"]).toBe("DE00");
50
+ });
51
+
52
+ test("no urlPrefillFields (screen not from buildAppSchema) prefills nothing from the URL", () => {
53
+ const result = mergeSearchParamsIntoInitial(fields, {
54
+ searchParams: { leaseId: "l-1", iban: "attacker-iban" },
55
+ urlPrefillFields: undefined,
56
+ });
57
+ expect(result["leaseId"]).toBe("");
58
+ expect(result["iban"]).toBe("DE00");
59
+ });
60
+
61
+ test("sensitive and password fields stay blocked even when allowlisted", () => {
62
+ const gated: Record<string, FieldDef> = {
63
+ secret: { type: "text", sensitive: true },
64
+ apiToken: { type: "text", format: "password", default: "unset" },
65
+ };
66
+ const result = mergeSearchParamsIntoInitial(gated, {
67
+ searchParams: { secret: "s", apiToken: "kpat_leak" },
68
+ urlPrefillFields: ["secret", "apiToken"],
69
+ });
70
+ expect(result["secret"]).toBe("");
71
+ expect(result["apiToken"]).toBe("unset");
72
+ });
73
+ });
74
+
75
+ describe("mergeSearchParamsIntoInitial — handoffValues", () => {
76
+ const fields: Record<string, FieldDef> = {
77
+ iban: { type: "text" },
78
+ amount: { type: "number", default: 0 },
79
+ secret: { type: "text", sensitive: true },
80
+ apiToken: { type: "text", format: "password" },
81
+ };
82
+
83
+ test("handed-off values prefill fields outside urlPrefillFields, strings coerced by field type", () => {
84
+ const result = mergeSearchParamsIntoInitial(fields, {
85
+ searchParams: {},
86
+ urlPrefillFields: [],
87
+ handoffValues: { iban: "DE12", amount: "42" },
88
+ });
89
+ expect(result["iban"]).toBe("DE12");
90
+ expect(result["amount"]).toBe(42);
91
+ });
92
+
93
+ test("handed-off values never reach sensitive or password fields", () => {
94
+ const result = mergeSearchParamsIntoInitial(fields, {
95
+ searchParams: {},
96
+ urlPrefillFields: [],
97
+ handoffValues: { secret: "s", apiToken: "kpat_leak" },
98
+ });
99
+ expect(result["secret"]).toBe("");
100
+ expect(result["apiToken"]).toBe("");
101
+ });
102
+
103
+ test("drawer overrides win over a handoff for the same field", () => {
104
+ const result = mergeSearchParamsIntoInitial(fields, {
105
+ searchParams: {},
106
+ urlPrefillFields: [],
107
+ drawerOverrides: { iban: "from-row" },
108
+ handoffValues: { iban: "from-handoff" },
109
+ });
110
+ expect(result["iban"]).toBe("from-row");
111
+ });
112
+ });
113
+
114
+ describe("mergeSearchParamsIntoInitial — coercion (every field URL-prefillable)", () => {
16
115
  test("raw string param merges in as-is for a text field", () => {
17
116
  const fields: Record<string, FieldDef> = { name: { type: "text" } };
18
- const result = mergeSearchParamsIntoInitial(fields, { name: "Alice" });
117
+ const result = mergeWithAllFieldsUrlPrefillable(fields, { name: "Alice" });
19
118
  expect(result["name"]).toBe("Alice");
20
119
  });
21
120
 
22
121
  test("number-type field coerces a numeric string", () => {
23
122
  const fields: Record<string, FieldDef> = { age: { type: "number" } };
24
- const result = mergeSearchParamsIntoInitial(fields, { age: "42" });
123
+ const result = mergeWithAllFieldsUrlPrefillable(fields, { age: "42" });
25
124
  expect(result["age"]).toBe(42);
26
125
  });
27
126
 
28
127
  test("invalid number string falls back to field default", () => {
29
128
  const fields: Record<string, FieldDef> = { count: { type: "number", default: 7 } };
30
- const result = mergeSearchParamsIntoInitial(fields, { count: "not-a-number" });
129
+ const result = mergeWithAllFieldsUrlPrefillable(fields, { count: "not-a-number" });
31
130
  expect(result["count"]).toBe(7);
32
131
  });
33
132
 
34
133
  test("boolean field coerces 'true' and 'false'", () => {
35
134
  const fields: Record<string, FieldDef> = { active: { type: "boolean" } };
36
- expect(mergeSearchParamsIntoInitial(fields, { active: "true" })["active"]).toBe(true);
37
- expect(mergeSearchParamsIntoInitial(fields, { active: "false" })["active"]).toBe(false);
135
+ expect(mergeWithAllFieldsUrlPrefillable(fields, { active: "true" })["active"]).toBe(true);
136
+ expect(mergeWithAllFieldsUrlPrefillable(fields, { active: "false" })["active"]).toBe(false);
38
137
  });
39
138
 
40
139
  test("sensitive field is skipped even when a matching searchParam exists", () => {
41
140
  const fields: Record<string, FieldDef> = { password: { type: "text", sensitive: true } };
42
- const result = mergeSearchParamsIntoInitial(fields, { password: "secret" });
141
+ const result = mergeWithAllFieldsUrlPrefillable(fields, { password: "secret" });
43
142
  expect(result["password"]).toBe("");
44
143
  });
45
144
 
@@ -47,31 +146,31 @@ describe("mergeSearchParamsIntoInitial", () => {
47
146
  const fields: Record<string, FieldDef> = {
48
147
  apiToken: { type: "text", format: "password", default: "unset" },
49
148
  };
50
- const result = mergeSearchParamsIntoInitial(fields, { apiToken: "kpat_leak" });
149
+ const result = mergeWithAllFieldsUrlPrefillable(fields, { apiToken: "kpat_leak" });
51
150
  expect(result["apiToken"]).toBe("unset");
52
151
  });
53
152
 
54
153
  test("field with no matching searchParam keeps its buildInitialValues default", () => {
55
154
  const fields: Record<string, FieldDef> = { total: { type: "number", default: 100 } };
56
- const result = mergeSearchParamsIntoInitial(fields, {});
155
+ const result = mergeWithAllFieldsUrlPrefillable(fields, {});
57
156
  expect(result["total"]).toBe(100);
58
157
  });
59
158
 
60
159
  test("money-type field without a defaultCurrency coerces a bare numeric string (legacy callers, e.g. config-edit/action-form)", () => {
61
160
  const fields: Record<string, FieldDef> = { price: { type: "money" } };
62
- const result = mergeSearchParamsIntoInitial(fields, { price: "19.99" });
161
+ const result = mergeWithAllFieldsUrlPrefillable(fields, { price: "19.99" });
63
162
  expect(result["price"]).toBe(19.99);
64
163
  });
65
164
 
66
165
  test("money-type field WITH a defaultCurrency merges the entityEdit payload shape (#1923)", () => {
67
166
  const fields: Record<string, FieldDef> = { price: { type: "money" } };
68
- const result = mergeSearchParamsIntoInitial(fields, { price: "19.99" }, undefined, "USD");
167
+ const result = mergeWithAllFieldsUrlPrefillable(fields, { price: "19.99" }, undefined, "USD");
69
168
  expect(result["price"]).toEqual({ amount: 19.99, currency: "USD" });
70
169
  });
71
170
 
72
171
  test("money-type field WITH a defaultCurrency but no matching searchParam still defaults to the object shape", () => {
73
172
  const fields: Record<string, FieldDef> = { price: { type: "money" } };
74
- const result = mergeSearchParamsIntoInitial(fields, {}, undefined, "USD");
173
+ const result = mergeWithAllFieldsUrlPrefillable(fields, {}, undefined, "USD");
75
174
  expect(result["price"]).toEqual({ amount: 0, currency: "USD" });
76
175
  });
77
176
 
@@ -80,7 +179,7 @@ describe("mergeSearchParamsIntoInitial", () => {
80
179
  status: { type: "text", default: "draft" },
81
180
  ownerId: { type: "text" },
82
181
  };
83
- const result = mergeSearchParamsIntoInitial(
182
+ const result = mergeWithAllFieldsUrlPrefillable(
84
183
  fields,
85
184
  { status: "approved", ownerId: "user-123" },
86
185
  new Set(["status"]),
@@ -91,16 +190,16 @@ describe("mergeSearchParamsIntoInitial", () => {
91
190
 
92
191
  test("no renderableFields set given (undefined): behaves as before, all fields eligible", () => {
93
192
  const fields: Record<string, FieldDef> = { ownerId: { type: "text" } };
94
- const result = mergeSearchParamsIntoInitial(fields, { ownerId: "user-123" });
193
+ const result = mergeWithAllFieldsUrlPrefillable(fields, { ownerId: "user-123" });
95
194
  expect(result["ownerId"]).toBe("user-123");
96
195
  });
97
196
 
98
197
  test("multiSelect coerces comma-separated searchParam to string[]", () => {
99
198
  const fields: Record<string, FieldDef> = { roles: { type: "multiSelect" } };
100
- expect(mergeSearchParamsIntoInitial(fields, { roles: "TenantAdmin" })["roles"]).toEqual([
199
+ expect(mergeWithAllFieldsUrlPrefillable(fields, { roles: "TenantAdmin" })["roles"]).toEqual([
101
200
  "TenantAdmin",
102
201
  ]);
103
- expect(mergeSearchParamsIntoInitial(fields, { roles: "Admin,User" })["roles"]).toEqual([
202
+ expect(mergeWithAllFieldsUrlPrefillable(fields, { roles: "Admin,User" })["roles"]).toEqual([
104
203
  "Admin",
105
204
  "User",
106
205
  ]);
@@ -109,19 +208,21 @@ describe("mergeSearchParamsIntoInitial", () => {
109
208
  test("multiSelect coerces JSON-array searchParam to string[]", () => {
110
209
  const fields: Record<string, FieldDef> = { roles: { type: "multiSelect" } };
111
210
  expect(
112
- mergeSearchParamsIntoInitial(fields, { roles: JSON.stringify(["Admin", "Editor"]) })["roles"],
211
+ mergeWithAllFieldsUrlPrefillable(fields, { roles: JSON.stringify(["Admin", "Editor"]) })[
212
+ "roles"
213
+ ],
113
214
  ).toEqual(["Admin", "Editor"]);
114
215
  });
115
216
 
116
217
  test("multiSelect defaults to [] when unset", () => {
117
218
  const fields: Record<string, FieldDef> = { roles: { type: "multiSelect" } };
118
- expect(mergeSearchParamsIntoInitial(fields, {})["roles"]).toEqual([]);
219
+ expect(mergeWithAllFieldsUrlPrefillable(fields, {})["roles"]).toEqual([]);
119
220
  });
120
221
 
121
222
  test("multiSelect prefers JSON parse even without '[' prefix for quoted strings", () => {
122
223
  const fields: Record<string, FieldDef> = { tags: { type: "multiSelect" } };
123
224
  expect(
124
- mergeSearchParamsIntoInitial(fields, { tags: JSON.stringify("Berlin, Germany") })["tags"],
225
+ mergeWithAllFieldsUrlPrefillable(fields, { tags: JSON.stringify("Berlin, Germany") })["tags"],
125
226
  ).toEqual(["Berlin, Germany"]);
126
227
  });
127
228
 
@@ -130,13 +231,15 @@ describe("mergeSearchParamsIntoInitial", () => {
130
231
  roles: { type: "multiSelect", options: ["Admin", "User"] },
131
232
  };
132
233
  expect(
133
- mergeSearchParamsIntoInitial(fields, { roles: JSON.stringify(["Admin", "Hacker"]) })["roles"],
234
+ mergeWithAllFieldsUrlPrefillable(fields, { roles: JSON.stringify(["Admin", "Hacker"]) })[
235
+ "roles"
236
+ ],
134
237
  ).toEqual(["Admin"]);
135
238
  });
136
239
 
137
240
  test("money-type field parses a JSON {amount, currency} param without a defaultCurrency (fw#2763)", () => {
138
241
  const fields: Record<string, FieldDef> = { price: { type: "money" } };
139
- const result = mergeSearchParamsIntoInitial(fields, {
242
+ const result = mergeWithAllFieldsUrlPrefillable(fields, {
140
243
  price: JSON.stringify({ amount: 19.99, currency: "CHF" }),
141
244
  });
142
245
  expect(result["price"]).toEqual({ amount: 19.99, currency: "CHF" });
@@ -144,7 +247,7 @@ describe("mergeSearchParamsIntoInitial", () => {
144
247
 
145
248
  test("money-type field: explicit JSON currency wins over defaultCurrency", () => {
146
249
  const fields: Record<string, FieldDef> = { price: { type: "money" } };
147
- const result = mergeSearchParamsIntoInitial(
250
+ const result = mergeWithAllFieldsUrlPrefillable(
148
251
  fields,
149
252
  { price: JSON.stringify({ amount: 19.99, currency: "CHF" }) },
150
253
  undefined,
@@ -157,7 +260,7 @@ describe("mergeSearchParamsIntoInitial", () => {
157
260
  const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
158
261
  try {
159
262
  const fields: Record<string, FieldDef> = { price: { type: "money" } };
160
- const result = mergeSearchParamsIntoInitial(fields, { price: "19.99" });
263
+ const result = mergeWithAllFieldsUrlPrefillable(fields, { price: "19.99" });
161
264
  expect(result["price"]).toBe(19.99);
162
265
  expect(warnSpy).toHaveBeenCalled();
163
266
  expect(warnSpy.mock.calls[0]?.[0]).toContain("price");
@@ -172,7 +275,9 @@ describe("mergeSearchParamsIntoInitial", () => {
172
275
  const fields: Record<string, FieldDef> = {
173
276
  price: { type: "money", default: { amount: 0, currency: "EUR" } },
174
277
  };
175
- const result = mergeSearchParamsIntoInitial(fields, { price: JSON.stringify({ amount: 5 }) });
278
+ const result = mergeWithAllFieldsUrlPrefillable(fields, {
279
+ price: JSON.stringify({ amount: 5 }),
280
+ });
176
281
  expect(result["price"]).toEqual({ amount: 0, currency: "EUR" });
177
282
  expect(warnSpy).toHaveBeenCalled();
178
283
  } finally {
@@ -186,7 +291,7 @@ describe("mergeSearchParamsIntoInitial", () => {
186
291
  const fields: Record<string, FieldDef> = {
187
292
  price: { type: "money", default: { amount: 0, currency: "EUR" } },
188
293
  };
189
- const result = mergeSearchParamsIntoInitial(fields, {
294
+ const result = mergeWithAllFieldsUrlPrefillable(fields, {
190
295
  price: JSON.stringify({ amount: 5, currency: "<script>" }),
191
296
  });
192
297
  expect(result["price"]).toEqual({ amount: 0, currency: "EUR" });
@@ -202,7 +307,7 @@ describe("mergeSearchParamsIntoInitial", () => {
202
307
  const fields: Record<string, FieldDef> = {
203
308
  price: { type: "money", default: { amount: 0, currency: "EUR" } },
204
309
  };
205
- const result = mergeSearchParamsIntoInitial(fields, { price: "1e999" });
310
+ const result = mergeWithAllFieldsUrlPrefillable(fields, { price: "1e999" });
206
311
  expect(result["price"]).toEqual({ amount: 0, currency: "EUR" });
207
312
  } finally {
208
313
  warnSpy.mockRestore();
@@ -230,12 +335,14 @@ describe("mergeSearchParamsIntoInitial", () => {
230
335
  { accountId: "bank", amount: 1299, qty: 2, posted: true, kind: "debit" },
231
336
  { accountId: "cash", amount: -1299, qty: 1, posted: false, kind: "credit" },
232
337
  ];
233
- const result = mergeSearchParamsIntoInitial(linesField(), { lines: JSON.stringify(rows) });
338
+ const result = mergeWithAllFieldsUrlPrefillable(linesField(), {
339
+ lines: JSON.stringify(rows),
340
+ });
234
341
  expect(result["lines"]).toEqual(rows);
235
342
  });
236
343
 
237
344
  test("money cells stay signed minor-unit integers", () => {
238
- const result = mergeSearchParamsIntoInitial(linesField(), {
345
+ const result = mergeWithAllFieldsUrlPrefillable(linesField(), {
239
346
  lines: JSON.stringify([{ accountId: "bank", amount: -4200 }]),
240
347
  });
241
348
  expect(result["lines"]).toEqual([{ accountId: "bank", amount: -4200 }]);
@@ -244,7 +351,7 @@ describe("mergeSearchParamsIntoInitial", () => {
244
351
  test("a fractional money cell rejects the whole prefill and warns", () => {
245
352
  const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
246
353
  try {
247
- const result = mergeSearchParamsIntoInitial(linesField(), {
354
+ const result = mergeWithAllFieldsUrlPrefillable(linesField(), {
248
355
  lines: JSON.stringify([{ accountId: "bank", amount: 12.99 }]),
249
356
  });
250
357
  expect(result["lines"]).toEqual([]);
@@ -258,7 +365,7 @@ describe("mergeSearchParamsIntoInitial", () => {
258
365
  test("a non-JSON param leaves the field empty and warns", () => {
259
366
  const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
260
367
  try {
261
- const result = mergeSearchParamsIntoInitial(linesField(), { lines: "not-json-at-all" });
368
+ const result = mergeWithAllFieldsUrlPrefillable(linesField(), { lines: "not-json-at-all" });
262
369
  expect(result["lines"]).toEqual([]);
263
370
  expect(warnSpy).toHaveBeenCalled();
264
371
  } finally {
@@ -269,7 +376,7 @@ describe("mergeSearchParamsIntoInitial", () => {
269
376
  test("a JSON object instead of an array leaves the field empty and warns", () => {
270
377
  const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
271
378
  try {
272
- const result = mergeSearchParamsIntoInitial(linesField(), {
379
+ const result = mergeWithAllFieldsUrlPrefillable(linesField(), {
273
380
  lines: JSON.stringify({ accountId: "bank" }),
274
381
  });
275
382
  expect(result["lines"]).toEqual([]);
@@ -282,7 +389,7 @@ describe("mergeSearchParamsIntoInitial", () => {
282
389
  test("a row that is not an object leaves the field empty and warns", () => {
283
390
  const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
284
391
  try {
285
- const result = mergeSearchParamsIntoInitial(linesField(), {
392
+ const result = mergeWithAllFieldsUrlPrefillable(linesField(), {
286
393
  lines: JSON.stringify([1, 2]),
287
394
  });
288
395
  expect(result["lines"]).toEqual([]);
@@ -295,7 +402,7 @@ describe("mergeSearchParamsIntoInitial", () => {
295
402
  test("a select cell outside the declared options rejects the prefill and warns", () => {
296
403
  const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
297
404
  try {
298
- const result = mergeSearchParamsIntoInitial(linesField(), {
405
+ const result = mergeWithAllFieldsUrlPrefillable(linesField(), {
299
406
  lines: JSON.stringify([{ accountId: "bank", kind: "sudo" }]),
300
407
  });
301
408
  expect(result["lines"]).toEqual([]);
@@ -306,14 +413,14 @@ describe("mergeSearchParamsIntoInitial", () => {
306
413
  });
307
414
 
308
415
  test("undeclared row keys are dropped instead of reaching the form", () => {
309
- const result = mergeSearchParamsIntoInitial(linesField(), {
416
+ const result = mergeWithAllFieldsUrlPrefillable(linesField(), {
310
417
  lines: JSON.stringify([{ accountId: "bank", secretFlag: "yes" }]),
311
418
  });
312
419
  expect(result["lines"]).toEqual([{ accountId: "bank" }]);
313
420
  });
314
421
 
315
422
  test("a __proto__ key in a row pollutes nothing", () => {
316
- const result = mergeSearchParamsIntoInitial(linesField(), {
423
+ const result = mergeWithAllFieldsUrlPrefillable(linesField(), {
317
424
  lines: '[{"accountId":"bank","__proto__":{"polluted":true}}]',
318
425
  });
319
426
  expect(result["lines"]).toEqual([{ accountId: "bank" }]);
@@ -324,7 +431,7 @@ describe("mergeSearchParamsIntoInitial", () => {
324
431
  test("more rows than maxItems leaves the field empty and warns", () => {
325
432
  const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
326
433
  try {
327
- const result = mergeSearchParamsIntoInitial(linesField({ maxItems: 2 }), {
434
+ const result = mergeWithAllFieldsUrlPrefillable(linesField({ maxItems: 2 }), {
328
435
  lines: JSON.stringify([{ accountId: "a" }, { accountId: "b" }, { accountId: "c" }]),
329
436
  });
330
437
  expect(result["lines"]).toEqual([]);
@@ -335,12 +442,12 @@ describe("mergeSearchParamsIntoInitial", () => {
335
442
  });
336
443
 
337
444
  test("an embedded list without a matching param defaults to an empty array", () => {
338
- const result = mergeSearchParamsIntoInitial(linesField(), {});
445
+ const result = mergeWithAllFieldsUrlPrefillable(linesField(), {});
339
446
  expect(result["lines"]).toEqual([]);
340
447
  });
341
448
 
342
449
  test("a sensitive embedded list ignores the param", () => {
343
- const result = mergeSearchParamsIntoInitial(linesField({ sensitive: true }), {
450
+ const result = mergeWithAllFieldsUrlPrefillable(linesField({ sensitive: true }), {
344
451
  lines: JSON.stringify([{ accountId: "bank" }]),
345
452
  });
346
453
  expect(result["lines"]).toEqual([]);
@@ -12,14 +12,14 @@ import type {
12
12
  EntityEditScreenDefinition,
13
13
  } from "@cosmicdrift/kumiko-framework/ui-types";
14
14
  import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
15
- import { render } from "@testing-library/react";
16
- import type { ComponentType, ReactNode } from "react";
15
+ import { act, render } from "@testing-library/react";
16
+ import { type ComponentType, type ReactNode, useState } from "react";
17
17
  import { DispatcherProvider } from "../../context/dispatcher-context";
18
18
  import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
19
19
  import { type CorePrimitives, type InputProps, PrimitivesProvider } from "../../primitives";
20
20
  import type { FeatureSchema } from "../feature-schema";
21
21
  import { KumikoScreen } from "../kumiko-screen";
22
- import { NavProvider } from "../nav";
22
+ import { type NavApi, NavProvider, type NavTarget, useNavigateWithInitialValues } from "../nav";
23
23
 
24
24
  const captured: Record<string, InputProps | undefined> = {};
25
25
  const captureInput: ComponentType<InputProps> = (props) => {
@@ -65,18 +65,29 @@ function stubDispatcher(): Dispatcher {
65
65
  };
66
66
  }
67
67
 
68
- function buildSchema(): FeatureSchema {
68
+ function buildSchema(urlPrefillFields: readonly string[] | undefined): FeatureSchema {
69
69
  const entity: EntityDefinition = {
70
70
  fields: {
71
71
  name: { type: "text", maxLength: 200, required: false, searchable: false, sortable: false },
72
72
  floorCount: { type: "number", required: false, sortable: false },
73
+ ownerEmail: { type: "text", maxLength: 200, required: false, searchable: false },
74
+ accessCode: {
75
+ type: "text",
76
+ maxLength: 200,
77
+ required: false,
78
+ searchable: false,
79
+ sensitive: true,
80
+ },
73
81
  },
74
82
  };
75
83
  const screen: EntityEditScreenDefinition = {
76
84
  id: "unit-edit",
77
85
  type: "entityEdit",
78
86
  entity: "unit",
79
- layout: { sections: [{ columns: 1, fields: ["name", "floorCount"] }] },
87
+ layout: {
88
+ sections: [{ columns: 1, fields: ["name", "floorCount", "ownerEmail", "accessCode"] }],
89
+ },
90
+ ...(urlPrefillFields !== undefined && { urlPrefillFields }),
80
91
  };
81
92
  return {
82
93
  featureName: "housing",
@@ -85,29 +96,41 @@ function buildSchema(): FeatureSchema {
85
96
  } as FeatureSchema;
86
97
  }
87
98
 
99
+ function staticNav(searchParams: Record<string, string>): NavApi {
100
+ return {
101
+ route: { screenId: "housing:unit-edit" },
102
+ navigate: () => {},
103
+ replace: () => {},
104
+ hrefFor: () => "",
105
+ searchParams,
106
+ setSearchParams: () => {},
107
+ };
108
+ }
109
+
110
+ function renderWithNav(nav: NavApi, schema: FeatureSchema): void {
111
+ render(
112
+ <LocaleProvider resolver={createStaticLocaleResolver({ locale: "de-DE" })}>
113
+ <DispatcherProvider dispatcher={stubDispatcher()}>
114
+ <NavProvider value={nav}>
115
+ <PrimitivesProvider value={testPrimitives}>
116
+ <KumikoScreen schema={schema} qn="housing:screen:unit-edit" />
117
+ </PrimitivesProvider>
118
+ </NavProvider>
119
+ </DispatcherProvider>
120
+ </LocaleProvider>,
121
+ );
122
+ }
123
+
124
+ function resetCaptured(): void {
125
+ for (const key of Object.keys(captured)) delete captured[key];
126
+ }
127
+
88
128
  describe("EntityEditCreateBody — navigate params as initial values (#1680)", () => {
89
129
  test("URL searchParams from rowAction navigate prefill the create form", () => {
90
- delete captured["name"];
91
- delete captured["floorCount"];
92
- render(
93
- <LocaleProvider resolver={createStaticLocaleResolver({ locale: "de-DE" })}>
94
- <DispatcherProvider dispatcher={stubDispatcher()}>
95
- <NavProvider
96
- value={{
97
- route: { screenId: "housing:unit-edit" },
98
- navigate: () => {},
99
- replace: () => {},
100
- hrefFor: () => "",
101
- searchParams: { name: "Erdgeschoss", floorCount: "3" },
102
- setSearchParams: () => {},
103
- }}
104
- >
105
- <PrimitivesProvider value={testPrimitives}>
106
- <KumikoScreen schema={buildSchema()} qn="housing:screen:unit-edit" />
107
- </PrimitivesProvider>
108
- </NavProvider>
109
- </DispatcherProvider>
110
- </LocaleProvider>,
130
+ resetCaptured();
131
+ renderWithNav(
132
+ staticNav({ name: "Erdgeschoss", floorCount: "3" }),
133
+ buildSchema(["name", "floorCount"]),
111
134
  );
112
135
 
113
136
  expect(captured["name"]?.value).toBe("Erdgeschoss");
@@ -115,28 +138,99 @@ describe("EntityEditCreateBody — navigate params as initial values (#1680)", (
115
138
  });
116
139
 
117
140
  test("without a matching searchParam the field keeps its default (empty)", () => {
118
- delete captured["name"];
119
- render(
141
+ resetCaptured();
142
+ renderWithNav(staticNav({}), buildSchema(["name"]));
143
+
144
+ expect(captured["name"]?.value).toBe("");
145
+ });
146
+
147
+ test("a crafted link cannot prefill a field outside urlPrefillFields", () => {
148
+ resetCaptured();
149
+ renderWithNav(
150
+ staticNav({ name: "Erdgeschoss", ownerEmail: "attacker@example.com" }),
151
+ buildSchema(["name"]),
152
+ );
153
+
154
+ expect(captured["name"]?.value).toBe("Erdgeschoss");
155
+ expect(captured["ownerEmail"]?.value).toBe("");
156
+ });
157
+
158
+ test("a screen without urlPrefillFields takes nothing from the URL", () => {
159
+ resetCaptured();
160
+ renderWithNav(staticNav({ name: "Erdgeschoss" }), buildSchema(undefined));
161
+
162
+ expect(captured["name"]?.value).toBe("");
163
+ });
164
+
165
+ test("a sensitive field stays empty even when urlPrefillFields names it", () => {
166
+ resetCaptured();
167
+ renderWithNav(staticNav({ accessCode: "1234" }), buildSchema(["accessCode"]));
168
+
169
+ expect(captured["accessCode"]?.value).toBe("");
170
+ });
171
+ });
172
+
173
+ describe("EntityEditCreateBody — useNavigateWithInitialValues", () => {
174
+ function HandoffApp({
175
+ schema,
176
+ initialValues,
177
+ }: {
178
+ readonly schema: FeatureSchema;
179
+ readonly initialValues: Readonly<Record<string, unknown>>;
180
+ }): ReactNode {
181
+ const [screenId, setScreenId] = useState("home");
182
+ const nav: NavApi = {
183
+ route: { screenId },
184
+ navigate: (target: NavTarget) => {
185
+ if ("screenId" in target) setScreenId(target.screenId);
186
+ },
187
+ replace: () => {},
188
+ hrefFor: () => "",
189
+ searchParams: {},
190
+ setSearchParams: () => {
191
+ throw new Error("the handoff must not write the query string");
192
+ },
193
+ };
194
+ return (
120
195
  <LocaleProvider resolver={createStaticLocaleResolver({ locale: "de-DE" })}>
121
196
  <DispatcherProvider dispatcher={stubDispatcher()}>
122
- <NavProvider
123
- value={{
124
- route: { screenId: "housing:unit-edit" },
125
- navigate: () => {},
126
- replace: () => {},
127
- hrefFor: () => "",
128
- searchParams: {},
129
- setSearchParams: () => {},
130
- }}
131
- >
197
+ <NavProvider value={nav}>
132
198
  <PrimitivesProvider value={testPrimitives}>
133
- <KumikoScreen schema={buildSchema()} qn="housing:screen:unit-edit" />
199
+ {screenId === "unit-edit" ? (
200
+ <KumikoScreen schema={schema} qn="housing:screen:unit-edit" />
201
+ ) : (
202
+ <Opener initialValues={initialValues} />
203
+ )}
134
204
  </PrimitivesProvider>
135
205
  </NavProvider>
136
206
  </DispatcherProvider>
137
- </LocaleProvider>,
207
+ </LocaleProvider>
138
208
  );
209
+ }
139
210
 
140
- expect(captured["name"]?.value).toBe("");
211
+ let openForm: (() => void) | undefined;
212
+ function Opener({
213
+ initialValues,
214
+ }: {
215
+ readonly initialValues: Readonly<Record<string, unknown>>;
216
+ }): ReactNode {
217
+ const navigateWithInitialValues = useNavigateWithInitialValues();
218
+ openForm = () => navigateWithInitialValues({ screenId: "unit-edit" }, initialValues);
219
+ return null;
220
+ }
221
+
222
+ test("handed-off values prefill fields outside urlPrefillFields, but never a sensitive one", () => {
223
+ resetCaptured();
224
+ render(
225
+ <HandoffApp
226
+ schema={buildSchema([])}
227
+ initialValues={{ ownerEmail: "owner@example.com", floorCount: "2", accessCode: "1234" }}
228
+ />,
229
+ );
230
+ act(() => openForm?.());
231
+
232
+ expect(captured["ownerEmail"]?.value).toBe("owner@example.com");
233
+ expect(captured["floorCount"]?.value).toBe(2);
234
+ expect(captured["accessCode"]?.value).toBe("");
141
235
  });
142
236
  });
@@ -69,7 +69,7 @@ import {
69
69
  type ResolvedFacetSpec,
70
70
  resolveProjectionFacetSpecs,
71
71
  } from "./list-facets";
72
- import { type NavApi, useNav } from "./nav";
72
+ import { type NavApi, useInitialValuesHandoff, useNav } from "./nav";
73
73
  import {
74
74
  synthesizeProjectionDetailEntity,
75
75
  synthesizeProjectionDetailScreen,
@@ -526,87 +526,116 @@ function coerceEmbeddedListRows(
526
526
  return rows;
527
527
  }
528
528
 
529
+ type PrefillFieldShape = {
530
+ readonly type?: string;
531
+ readonly sensitive?: boolean;
532
+ readonly format?: string;
533
+ readonly options?: readonly (string | { readonly value: string })[];
534
+ readonly multiple?: boolean;
535
+ readonly schema?: Readonly<Record<string, EmbeddedCellShape>>;
536
+ readonly maxItems?: number;
537
+ };
538
+
539
+ export type InitialValueSources = {
540
+ readonly searchParams: Readonly<Record<string, string>>;
541
+ // Only these URL query keys may prefill — the target screen's
542
+ // `urlPrefillFields` from buildAppSchema. Absent means none: a crafted link
543
+ // must not seed fields no declared navigate `params` names.
544
+ readonly urlPrefillFields: readonly string[] | undefined;
545
+ readonly renderableFields?: ReadonlySet<string>;
546
+ readonly defaultCurrency?: string;
547
+ // Drawer-kind row actions (fw#2710) prefill from the clicked row's
548
+ // already-typed values; wins over every other source.
549
+ readonly drawerOverrides?: Readonly<Record<string, unknown>>;
550
+ // useNavigateWithInitialValues — in-app only, never in the URL, so it
551
+ // bypasses urlPrefillFields; string values get the URL coercion.
552
+ readonly handoffValues?: Readonly<Record<string, unknown>>;
553
+ };
554
+
555
+ function coercePrefillString(
556
+ raw: string,
557
+ name: string,
558
+ shape: PrefillFieldShape,
559
+ fallback: unknown,
560
+ defaultCurrency: string | undefined,
561
+ ): unknown {
562
+ if (shape.type === "number") {
563
+ const parsed = Number(raw);
564
+ return Number.isNaN(parsed) ? fallback : parsed;
565
+ }
566
+ if (shape.type === "money") {
567
+ const coerced = coerceMoneyValue(raw, name, defaultCurrency);
568
+ return coerced === undefined ? fallback : coerced;
569
+ }
570
+ if (shape.type === "boolean") return raw === "true";
571
+ if (shape.type === "multiSelect") {
572
+ // Row-action navigate stringifies arrays via String(arr) → "a,b" (or
573
+ // JSON when the navigate helper JSON.stringifies). Try JSON first
574
+ // (even without a "[" prefix), then comma-split; filter against
575
+ // field.options when present so unknown values never reach the form.
576
+ const optionValues = multiSelectOptionValues(shape);
577
+ const splitComma = (): string[] =>
578
+ raw === ""
579
+ ? []
580
+ : raw
581
+ .split(",")
582
+ .map((s) => s.trim())
583
+ .filter(Boolean);
584
+ let values: string[];
585
+ try {
586
+ const parsed: unknown = JSON.parse(raw);
587
+ if (Array.isArray(parsed)) {
588
+ values = parsed.map((v) => String(v));
589
+ } else if (typeof parsed === "string") {
590
+ values = [parsed];
591
+ } else {
592
+ values = splitComma();
593
+ }
594
+ } catch {
595
+ values = splitComma();
596
+ }
597
+ return optionValues !== undefined ? values.filter((v) => optionValues.has(v)) : values;
598
+ }
599
+ if (shape.type === "embedded" && shape.multiple === true) {
600
+ return coerceEmbeddedListRows(raw, name, shape.schema ?? {}, shape.maxItems) ?? fallback;
601
+ }
602
+ return raw;
603
+ }
604
+
529
605
  export function mergeSearchParamsIntoInitial(
530
606
  fields: Readonly<Record<string, unknown>>,
531
- searchParams: Readonly<Record<string, string>>,
532
- renderableFields?: ReadonlySet<string>,
533
- defaultCurrency?: string,
534
- // Drawer-kind row actions (fw#2710) prefill directly from the clicked
535
- // row's already-typed values — no URL round-trip, so no string coercion.
536
- // Goes through the same renderableFields/sensitive gates as searchParams
537
- // (a `params` extractor could still name a hidden or sensitive field) and
538
- // wins over searchParams for a name present in both.
539
- overrides?: Readonly<Record<string, unknown>>,
607
+ sources: InitialValueSources,
540
608
  ): Record<string, unknown> {
609
+ const { searchParams, renderableFields, defaultCurrency, drawerOverrides, handoffValues } =
610
+ sources;
611
+ const urlPrefillFields = new Set(sources.urlPrefillFields ?? []);
541
612
  const defaults = buildInitialValues(fields, defaultCurrency) as Record<string, unknown>;
542
613
  const merged: Record<string, unknown> = { ...defaults };
543
614
  for (const [name, fieldDef] of Object.entries(fields)) {
544
615
  if (renderableFields !== undefined && !renderableFields.has(name)) continue;
545
- const shape = fieldDef as {
546
- type?: string;
547
- sensitive?: boolean;
548
- format?: string;
549
- options?: readonly (string | { readonly value: string })[];
550
- multiple?: boolean;
551
- schema?: Readonly<Record<string, EmbeddedCellShape>>;
552
- maxItems?: number;
553
- };
616
+ const shape = fieldDef as PrefillFieldShape;
617
+ // Neither gate is lifted by an allowlist entry or a handoff.
554
618
  if (shape.sensitive === true) continue;
555
- // A password field must never be prefilled from the URL, same as sensitive.
556
619
  if (shape.format === "password") continue;
557
- if (overrides !== undefined && name in overrides) {
558
- merged[name] = overrides[name];
620
+ if (drawerOverrides !== undefined && name in drawerOverrides) {
621
+ merged[name] = drawerOverrides[name];
559
622
  continue;
560
623
  }
561
- const raw = searchParams[name];
562
- if (raw === undefined) continue;
563
- if (shape.type === "number") {
564
- const parsed = Number(raw);
565
- merged[name] = Number.isNaN(parsed) ? defaults[name] : parsed;
566
- } else if (shape.type === "money") {
567
- const coerced = coerceMoneyValue(raw, name, defaultCurrency);
568
- merged[name] = coerced === undefined ? defaults[name] : coerced;
569
- } else if (shape.type === "boolean") {
570
- merged[name] = raw === "true";
571
- } else if (shape.type === "multiSelect") {
572
- // Row-action navigate stringifies arrays via String(arr) → "a,b" (or
573
- // JSON when the navigate helper JSON.stringifies). Try JSON first
574
- // (even without a "[" prefix), then comma-split; filter against
575
- // field.options when present so unknown values never reach the form.
576
- const optionValues = multiSelectOptionValues(shape);
577
- let values: string[];
578
- try {
579
- const parsed: unknown = JSON.parse(raw);
580
- if (Array.isArray(parsed)) {
581
- values = parsed.map((v) => String(v));
582
- } else if (typeof parsed === "string") {
583
- values = [parsed];
584
- } else {
585
- values =
586
- raw === ""
587
- ? []
588
- : raw
589
- .split(",")
590
- .map((s) => s.trim())
591
- .filter(Boolean);
592
- }
593
- } catch {
594
- values =
595
- raw === ""
596
- ? []
597
- : raw
598
- .split(",")
599
- .map((s) => s.trim())
600
- .filter(Boolean);
601
- }
624
+ const handedOff =
625
+ handoffValues !== undefined && Object.hasOwn(handoffValues, name)
626
+ ? handoffValues[name]
627
+ : undefined;
628
+ if (handedOff !== undefined) {
602
629
  merged[name] =
603
- optionValues !== undefined ? values.filter((v) => optionValues.has(v)) : values;
604
- } else if (shape.type === "embedded" && shape.multiple === true) {
605
- const rows = coerceEmbeddedListRows(raw, name, shape.schema ?? {}, shape.maxItems);
606
- merged[name] = rows ?? defaults[name];
607
- } else {
608
- merged[name] = raw;
630
+ typeof handedOff === "string"
631
+ ? coercePrefillString(handedOff, name, shape, defaults[name], defaultCurrency)
632
+ : handedOff;
633
+ continue;
609
634
  }
635
+ if (!urlPrefillFields.has(name)) continue;
636
+ const raw = searchParams[name];
637
+ if (raw === undefined) continue;
638
+ merged[name] = coercePrefillString(raw, name, shape, defaults[name], defaultCurrency);
610
639
  }
611
640
  return merged;
612
641
  }
@@ -691,16 +720,18 @@ function EntityEditCreateBody({
691
720
  readonly onSaved?: () => void;
692
721
  }): ReactNode {
693
722
  const nav = useNav();
723
+ const handoffValues = useInitialValuesHandoff(screen.id);
694
724
  const appFeatures = useAppFeatures();
695
725
  const initial = useMemo(
696
726
  () =>
697
- mergeSearchParamsIntoInitial(
698
- entity.fields,
699
- nav.searchParams,
700
- layoutFieldNames(screen),
701
- entity.defaultCurrency ?? "EUR",
702
- ) as FormValues,
703
- [entity.fields, nav.searchParams, screen, entity.defaultCurrency],
727
+ mergeSearchParamsIntoInitial(entity.fields, {
728
+ searchParams: nav.searchParams,
729
+ urlPrefillFields: screen.urlPrefillFields,
730
+ renderableFields: layoutFieldNames(screen),
731
+ defaultCurrency: entity.defaultCurrency ?? "EUR",
732
+ ...(handoffValues !== undefined && { handoffValues }),
733
+ }) as FormValues,
734
+ [entity.fields, nav.searchParams, screen, entity.defaultCurrency, handoffValues],
704
735
  );
705
736
  const formSchema = useMemo(() => buildFormSchema(entity, screen), [entity, screen]);
706
737
  const writeCommand = entityWriteCommand(schema.featureName, screen.entity, "create");
@@ -3038,16 +3069,26 @@ function ActionFormBody({
3038
3069
  const appFeatures = useAppFeatures();
3039
3070
  const synthEntity = useMemo(() => synthesizeActionFormEntity(screen.fields), [screen.fields]);
3040
3071
  const synthScreen = useMemo(() => synthesizeActionFormScreen(screen), [screen]);
3072
+ const pendingHandoff = useInitialValuesHandoff(screen.id);
3073
+ // A drawer-hosted form is not a navigation target, so it never takes a handoff.
3074
+ const handoffValues = onSuccess === undefined ? pendingHandoff : undefined;
3041
3075
  const initial = useMemo(
3042
3076
  () =>
3043
- mergeSearchParamsIntoInitial(
3044
- screen.fields,
3045
- nav.searchParams,
3046
- layoutFieldNames(synthScreen),
3047
- undefined,
3048
- initialOverrides,
3049
- ) as FormValues,
3050
- [screen.fields, nav.searchParams, synthScreen, initialOverrides],
3077
+ mergeSearchParamsIntoInitial(screen.fields, {
3078
+ searchParams: nav.searchParams,
3079
+ urlPrefillFields: screen.urlPrefillFields,
3080
+ renderableFields: layoutFieldNames(synthScreen),
3081
+ ...(initialOverrides !== undefined && { drawerOverrides: initialOverrides }),
3082
+ ...(handoffValues !== undefined && { handoffValues }),
3083
+ }) as FormValues,
3084
+ [
3085
+ screen.fields,
3086
+ screen.urlPrefillFields,
3087
+ nav.searchParams,
3088
+ synthScreen,
3089
+ initialOverrides,
3090
+ handoffValues,
3091
+ ],
3051
3092
  );
3052
3093
  const handleSubmitted = useCallback(
3053
3094
  (result: SubmitResult<unknown>) => {
package/src/app/nav.tsx CHANGED
@@ -1,5 +1,6 @@
1
- import { createContext, type ReactNode, useContext } from "react";
1
+ import { createContext, type ReactNode, useCallback, useContext, useEffect, useState } from "react";
2
2
  import type { FeatureSchema } from "./feature-schema";
3
+ import { lastSegment } from "./qn";
3
4
 
4
5
  // Navigation-Contract, plattform-neutral. Types + Context + Hook leben
5
6
  // hier; die konkrete Implementation (window.history im Web,
@@ -173,8 +174,77 @@ export type NavProviderProps = {
173
174
  readonly value: NavApi;
174
175
  };
175
176
 
177
+ type InitialValuesHandoff = {
178
+ readonly screenId: string;
179
+ readonly values: Readonly<Record<string, unknown>>;
180
+ };
181
+
182
+ // A mutable slot instead of state: offering must not re-render the app, and
183
+ // the target form reads it once on mount.
184
+ type InitialValuesHandoffSlot = { current: InitialValuesHandoff | undefined };
185
+
186
+ const InitialValuesHandoffContext = createContext<InitialValuesHandoffSlot | undefined>(undefined);
187
+
176
188
  export function NavProvider({ children, value }: NavProviderProps): ReactNode {
177
- return <NavContext.Provider value={value}>{children}</NavContext.Provider>;
189
+ const [handoffSlot] = useState<InitialValuesHandoffSlot>(() => ({ current: undefined }));
190
+ const routeScreenId = value.route?.screenId;
191
+ // An offer whose form never mounted (e.g. already on that screen) must not
192
+ // resurface on a later, unrelated visit — drop it once the route moves elsewhere.
193
+ useEffect(() => {
194
+ const pending = handoffSlot.current;
195
+ if (pending === undefined || routeScreenId === undefined) return;
196
+ if (lastSegment(routeScreenId) !== pending.screenId) handoffSlot.current = undefined;
197
+ }, [handoffSlot, routeScreenId]);
198
+ return (
199
+ <NavContext.Provider value={value}>
200
+ <InitialValuesHandoffContext.Provider value={handoffSlot}>
201
+ {children}
202
+ </InitialValuesHandoffContext.Provider>
203
+ </NavContext.Provider>
204
+ );
205
+ }
206
+
207
+ // Navigates to a form screen (actionForm, secretMint, entityEdit-create) with
208
+ // initial values carried in memory — never in the URL, so a link cannot
209
+ // forge them and they are not limited to the screen's urlPrefillFields.
210
+ // sensitive and password fields are still never prefilled.
211
+ export function useNavigateWithInitialValues(): (
212
+ target: ScreenTarget,
213
+ initialValues: Readonly<Record<string, unknown>>,
214
+ ) => void {
215
+ const nav = useNav();
216
+ const handoffSlot = useContext(InitialValuesHandoffContext);
217
+ return useCallback(
218
+ (target, initialValues) => {
219
+ if (handoffSlot !== undefined) {
220
+ handoffSlot.current = { screenId: lastSegment(target.screenId), values: initialValues };
221
+ }
222
+ nav.navigate(target);
223
+ },
224
+ [nav, handoffSlot],
225
+ );
226
+ }
227
+
228
+ export function useInitialValuesHandoff(
229
+ screenId: string,
230
+ ): Readonly<Record<string, unknown>> | undefined {
231
+ const handoffSlot = useContext(InitialValuesHandoffContext);
232
+ const [values] = useState(() => {
233
+ const pending = handoffSlot?.current;
234
+ return pending !== undefined && pending.screenId === lastSegment(screenId)
235
+ ? pending.values
236
+ : undefined;
237
+ });
238
+ useEffect(() => {
239
+ if (
240
+ handoffSlot !== undefined &&
241
+ values !== undefined &&
242
+ handoffSlot.current?.values === values
243
+ ) {
244
+ handoffSlot.current = undefined;
245
+ }
246
+ }, [handoffSlot, values]);
247
+ return values;
178
248
  }
179
249
 
180
250
  export function useNav(): NavApi {
@@ -12,7 +12,7 @@ import {
12
12
  import type { FeatureSchema } from "./feature-schema";
13
13
  import { buildInitialValues, mergeSearchParamsIntoInitial } from "./kumiko-screen";
14
14
  import { layoutFieldNames } from "./layout-fields";
15
- import { useNav } from "./nav";
15
+ import { useInitialValuesHandoff, useNav } from "./nav";
16
16
  import { lastSegment } from "./qn";
17
17
 
18
18
  export type SecretMintBodyProps = {
@@ -62,14 +62,16 @@ export function SecretMintBody({ schema, screen, translate }: SecretMintBodyProp
62
62
  const effectiveTranslate = translate ?? t;
63
63
  const synthEntity = useMemo(() => synthesizeActionFormEntity(screen.fields), [screen.fields]);
64
64
  const synthScreen = useMemo(() => synthesizeActionFormScreen(screen), [screen]);
65
+ const handoffValues = useInitialValuesHandoff(screen.id);
65
66
  const initial = useMemo(
66
67
  () =>
67
- mergeSearchParamsIntoInitial(
68
- screen.fields,
69
- nav.searchParams,
70
- layoutFieldNames(synthScreen),
71
- ) as FormValues,
72
- [screen.fields, nav.searchParams, synthScreen],
68
+ mergeSearchParamsIntoInitial(screen.fields, {
69
+ searchParams: nav.searchParams,
70
+ urlPrefillFields: screen.urlPrefillFields,
71
+ renderableFields: layoutFieldNames(synthScreen),
72
+ ...(handoffValues !== undefined && { handoffValues }),
73
+ }) as FormValues,
74
+ [screen.fields, screen.urlPrefillFields, nav.searchParams, synthScreen, handoffValues],
73
75
  );
74
76
  const [revealed, setRevealed] = useState<Readonly<Record<string, unknown>> | null>(null);
75
77
  const [done, setDone] = useState(false);
@@ -5,6 +5,11 @@
5
5
  // never a value conversion.
6
6
 
7
7
  import { describe, expect, test } from "bun:test";
8
+ import {
9
+ buildAppSchema,
10
+ createRegistry,
11
+ defineFeature,
12
+ } from "@cosmicdrift/kumiko-framework/engine";
8
13
  import type {
9
14
  EntityDefinition,
10
15
  EntityEditScreenDefinition,
@@ -189,3 +194,34 @@ describe("RenderField — editable number unit suffix", () => {
189
194
  expect(field.unit).toBeUndefined();
190
195
  });
191
196
  });
197
+
198
+ describe("RenderField — number unit through the shipped client schema", () => {
199
+ function projectedVehicleEntity(
200
+ mileageUnit: string | { readonly field: string },
201
+ ): EntityDefinition {
202
+ const feature = defineFeature("fleet", (r) => {
203
+ r.entity("vehicle", buildEntity(mileageUnit));
204
+ });
205
+ const app = buildAppSchema(createRegistry([feature]));
206
+ const entity = app.features[0]?.entities["vehicle"];
207
+ if (entity === undefined) throw new Error("expected the projected vehicle entity");
208
+ // Mirrors the browser-injection pipeline, which ships the schema as JSON.
209
+ return JSON.parse(JSON.stringify(entity)) as EntityDefinition;
210
+ }
211
+
212
+ test("static unit survives buildAppSchema and reaches Input.unit", () => {
213
+ const field = renderMileageField(projectedVehicleEntity("mi"), { mileage: 58 });
214
+ if (field.kind !== "number") throw new Error("expected a number Input");
215
+ expect(field.unit).toBe("mi");
216
+ });
217
+
218
+ test("sibling-field unit survives buildAppSchema and resolves from the row", () => {
219
+ const field = renderMileageField(
220
+ projectedVehicleEntity({ field: "mileageUnit" }),
221
+ { mileage: 58, mileageUnit: "km" },
222
+ { mileage: 58, mileageUnit: "km" },
223
+ );
224
+ if (field.kind !== "number") throw new Error("expected a number Input");
225
+ expect(field.unit).toBe("km");
226
+ });
227
+ });
package/src/index.ts CHANGED
@@ -79,6 +79,7 @@ export {
79
79
  parsePath,
80
80
  resolveTarget,
81
81
  useNav,
82
+ useNavigateWithInitialValues,
82
83
  } from "./app/nav";
83
84
  export { lastSegment } from "./app/qn";
84
85
  export type { EmbeddedScreenTarget } from "./app/use-embedded-screen";
@@ -51,6 +51,7 @@ import {
51
51
  type ComponentType,
52
52
  createContext,
53
53
  type FormEvent,
54
+ type KeyboardEvent,
54
55
  type ReactNode,
55
56
  type Ref,
56
57
  useContext,
@@ -60,6 +61,11 @@ export type { RuntimeRenderer };
60
61
 
61
62
  // ---- Prop-Types (die Primitive-Contract-Oberfläche) ----
62
63
 
64
+ /** Escape hatch for `data-*` test hooks (e.g. an E2E selector) that don't
65
+ * warrant a dedicated typed prop. Web merges these onto the rendered
66
+ * element; native impls ignore it (precedent: `className`). */
67
+ export type DataAttributes = Readonly<Record<`data-${string}`, string>>;
68
+
63
69
  /** Standard-Button. `loading` zeigt einen Spinner statt der Children
64
70
  * und sollte mit `disabled` kombiniert werden, wenn die Action wirklich
65
71
  * blockiert bis das Loading durch ist (z.B. async submit). Native-
@@ -105,6 +111,7 @@ export type ButtonProps = {
105
111
  readonly icon?: NavIconKey;
106
112
  /** Icon after the label (e.g. "Continue →"). */
107
113
  readonly iconEnd?: NavIconKey;
114
+ readonly dataAttributes?: DataAttributes;
108
115
  };
109
116
 
110
117
  /** Navigations-Link. `variant="button"` rendert die Button-Optik auf einem
@@ -121,6 +128,7 @@ export type LinkProps = {
121
128
  readonly className?: string;
122
129
  readonly children: ReactNode;
123
130
  readonly testId?: string;
131
+ readonly dataAttributes?: DataAttributes;
124
132
  };
125
133
 
126
134
  /** Banner für inline-Message ODER Page-State (z.B. "Loading…",
@@ -197,6 +205,11 @@ export type InputProps =
197
205
  readonly readOnly?: boolean;
198
206
  /** Closed FieldIconKey vocabulary (FIELD_ICONS registry, renderer-web). */
199
207
  readonly icon?: FieldIconKey;
208
+ readonly dataAttributes?: DataAttributes;
209
+ /** Raw keydown access (e.g. Enter-to-submit at the field instead of
210
+ * the surrounding Form). Web forwards it, native impls ignore it
211
+ * (precedent: `ButtonProps.ref`). */
212
+ readonly onKeyDown?: (event: KeyboardEvent<HTMLInputElement>) => void;
200
213
  }
201
214
  | {
202
215
  readonly kind: "email";
@@ -479,6 +492,12 @@ export type InputProps =
479
492
  /** Ctrl/Cmd+Enter submits instead of inserting a newline. Plain
480
493
  * Enter still inserts a newline. */
481
494
  readonly onSubmitShortcut?: () => void;
495
+ readonly dataAttributes?: DataAttributes;
496
+ /** Raw keydown access (e.g. plain-Enter-submits/Shift+Enter-newline at
497
+ * the field instead of the surrounding Form). Composes with
498
+ * `onSubmitShortcut` — both fire on the same keystroke when set. Web
499
+ * forwards it, native impls ignore it (precedent: `ButtonProps.ref`). */
500
+ readonly onKeyDown?: (event: KeyboardEvent<HTMLTextAreaElement>) => void;
482
501
  };
483
502
 
484
503
  // Sort-Wire-Format. `null`-State unterscheidet "User hat noch nichts
@@ -1033,6 +1052,7 @@ export type CardProps = {
1033
1052
  readonly children?: ReactNode;
1034
1053
  readonly className?: string;
1035
1054
  readonly testId?: string;
1055
+ readonly dataAttributes?: DataAttributes;
1036
1056
  };
1037
1057
 
1038
1058
  /** Determinate progress bar (e.g. wizard step progress). `value` is a