fix: address Copilot review on the nullable-union collapse

Four findings, all valid:

1. Returning `T` was unsound. The collapse genuinely changes two fields —
   `type` stops being an array, `anyOf` becomes `undefined` — so a caller
   whose `T` typed either that way could keep treating it so afterwards.
   The result is now `T | NormalizedNullableUnion<T>`, which states exactly
   that: `Omit<T, "type" | "anyOf">` plus a `RenderableType` `type`, a
   cleared `anyOf`, and `nullable`. The one assertion left is isolated in a
   `collapsed()` helper, needed only because `Omit` stays deferred while `T`
   is open.

2. A typeless `enum` does not imply strings. JSON Schema allows
   `enum: [1, 2]`, and inferring `"string"` for it would hand numbers to a
   `Select` declared `string[]`, and make the TUI submit `"1"` for `1`. Now
   only an all-string enum earns the inference; anything else stays on the
   JSON fallback. An explicit `type` is still authoritative.

3. `hasMissingRequiredFields` counted every null as missing, so clearing a
   *required* nullable enum — newly possible via the clear button — would
   disable submit on a value the schema permits. `required` constrains
   presence, not content, so null now counts as present exactly when the
   field's schema admits it.

4. `collectSchemaDefaults` read the raw union, so a default nested inside a
   nullable object's branch was displayed by the form but never seeded into
   the submitted values. It normalizes each property first, like the
   renderer does.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-15 17:24:46 -04:00
commit e4e16bb3e6
3 changed files with 207 additions and 20 deletions

View file

@ -144,6 +144,39 @@ describe("normalizeNullableUnion", () => {
});
});
// JSON Schema's `enum` is untyped, so a typeless branch only implies strings
// when every member is one. Guessing otherwise would hand a number to a
// renderer that declared the option list `string[]`.
it("does not infer string for a typeless non-string enum branch", () => {
const schema = { anyOf: [{ enum: [1, 2] }, { type: "null" as const }] };
expect(normalizeNullableUnion(schema)).toBe(schema);
});
it("does not infer string for a typeless mixed enum branch", () => {
const schema = { anyOf: [{ enum: ["a", 2] }, { type: "null" as const }] };
expect(normalizeNullableUnion(schema)).toBe(schema);
});
it("does not infer string for a typeless empty enum branch", () => {
const schema = { anyOf: [{ enum: [] }, { type: "null" as const }] };
expect(normalizeNullableUnion(schema)).toBe(schema);
});
// An explicit `type` is authoritative — the enum members are the server's
// problem at that point, not an inference this function is making.
it("still collapses a non-string enum branch that declares its type", () => {
expect(
normalizeNullableUnion({
anyOf: [{ type: "number", enum: [1, 2] }, { type: "null" }],
}),
).toEqual({
type: "number",
enum: [1, 2],
anyOf: undefined,
nullable: true,
});
});
it("infers string for a typeless enum branch", () => {
expect(
normalizeNullableUnion({

View file

@ -221,6 +221,42 @@ describe("collectSchemaDefaults", () => {
it("returns an empty object when the schema has no properties", () => {
expect(collectSchemaDefaults({ type: "object" })).toEqual({});
});
// The form normalizes a nullable union before rendering, so this has to as
// well: a nested object's `properties` live on the union's surviving branch,
// and a default only visible there would be displayed but never submitted.
it("collects defaults hoisted out of a nullable object union", () => {
const schema: InspectorFormSchema = {
type: "object",
properties: {
profile: {
anyOf: [
{
type: "object",
properties: { nick: { type: "string", default: "ada" } },
},
{ type: "null" },
],
},
},
};
expect(collectSchemaDefaults(schema)).toEqual({
profile: { nick: "ada" },
});
});
it("keeps a default declared on the union wrapper itself", () => {
const schema: InspectorFormSchema = {
type: "object",
properties: {
direction: {
default: "envio",
anyOf: [{ type: "string", enum: ["envio"] }, { type: "null" }],
},
},
};
expect(collectSchemaDefaults(schema)).toEqual({ direction: "envio" });
});
});
describe("hasMissingRequiredFields", () => {
@ -247,4 +283,44 @@ describe("hasMissingRequiredFields", () => {
false,
);
});
// JSON Schema `required` constrains presence, not content — so an explicit
// `null` satisfies a required field whose schema admits null. Since #1928 the
// user can produce exactly that by clearing a nullable enum, and treating it
// as missing would disable submit on a value the schema calls valid.
it("accepts an explicit null for a required field that admits null", () => {
const nullable: InspectorFormSchema = {
type: "object",
properties: {
direction: {
anyOf: [{ type: "string", enum: ["envio"] }, { type: "null" }],
},
},
required: ["direction"],
};
expect(hasMissingRequiredFields(nullable, { direction: null })).toBe(false);
expect(hasMissingRequiredFields(nullable, {})).toBe(true);
});
it("accepts an explicit null for a required type: [T, null] field", () => {
const nullable: InspectorFormSchema = {
type: "object",
properties: { note: { type: ["string", "null"] } },
required: ["note"],
};
expect(hasMissingRequiredFields(nullable, { note: null })).toBe(false);
});
it("still rejects null for a required field whose schema is not nullable", () => {
expect(hasMissingRequiredFields(schema, { name: null })).toBe(true);
});
it("rejects null for a required field the schema does not describe", () => {
const undescribed: InspectorFormSchema = {
type: "object",
properties: {},
required: ["ghost"],
};
expect(hasMissingRequiredFields(undescribed, { ghost: null })).toBe(true);
});
});

View file

@ -1,3 +1,5 @@
import { normalizeNullableUnion } from "@inspector/core/json/nullableUnion.js";
export type JsonValue =
| string
| number
@ -116,7 +118,12 @@ export function collectSchemaDefaults(
): Record<string, unknown> {
const properties = schema.properties ?? {};
const result: Record<string, unknown> = {};
for (const [fieldName, fieldSchema] of Object.entries(properties)) {
for (const [fieldName, rawSchema] of Object.entries(properties)) {
// Collapse a nullable union first, for the same reason `SchemaForm` does:
// a nested object's `properties` live on the union's surviving branch, so
// without this the form would *display* a hoisted default that never
// reached the seeded values — the field would submit empty (#1928).
const fieldSchema = normalizeNullableUnion(rawSchema);
if (fieldSchema.default !== undefined) {
result[fieldName] = fieldSchema.default;
} else if (fieldSchema.type === "object" && fieldSchema.properties) {
@ -133,15 +140,29 @@ export function collectSchemaDefaults(
* Whether any of the schema's required top-level fields is missing a value in
* `values` (absent, null, or empty string). Used to gate a form's submit
* action until required fields are supplied.
*
* `null` counts as missing only for a field that does not admit it. JSON
* Schema's `required` constrains *presence*, not content, so a required field
* whose schema is a nullable union is satisfied by an explicit `null` — and
* since #1928 the user can produce exactly that by clearing a nullable enum.
* Treating it as missing would leave the form's submit permanently disabled on
* a value the schema calls valid.
*/
export function hasMissingRequiredFields(
schema: InspectorFormSchema,
values: Record<string, unknown>,
): boolean {
const required = schema.required ?? [];
const properties = schema.properties ?? {};
return required.some((field) => {
const value = values[field];
return value === undefined || value === null || value === "";
if (value === null) {
const fieldSchema = properties[field];
return fieldSchema === undefined
? true
: normalizeNullableUnion(fieldSchema).nullable !== true;
}
return value === undefined || value === "";
});
}