fix: address Copilot review round 18 — empty-string constants, nested branch reset

- TUI missingRequiredFields treats an empty string as supplied when the
  property's own `const` pins it there. The one-option control cannot produce
  anything else, so reporting the seeded value as missing made such a branch
  permanently uncallable.
- A nested SchemaForm is reset by the branch-aware key too. Two outer
  alternatives can both carry the same nested object field, and a nested form
  left mounted across an outer switch kept displaying the inner branch chosen
  for the other one while the newly seeded values described another shape.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-26 06:55:20 -04:00
commit 36cf58fe6c
4 changed files with 76 additions and 3 deletions

View file

@ -837,6 +837,24 @@ describe("schemaToForm", () => {
expect(missingRequiredFields(nullable, { a: null })).toEqual([]);
});
it("accepts an empty string a const pins the field to", () => {
// The one-option control cannot produce anything else, so reporting the
// seeded value as missing would make the branch permanently uncallable.
const pinnedEmpty = {
type: "object",
properties: { kind: { type: "string", const: "" } },
required: ["kind"],
};
expect(missingRequiredFields(pinnedEmpty, { kind: "" })).toEqual([]);
// An ordinary required string is still missing when left blank.
const ordinary = {
type: "object",
properties: { kind: { type: "string" } },
required: ["kind"],
};
expect(missingRequiredFields(ordinary, { kind: "" })).toEqual(["kind"]);
});
it("checks the root's own required fields when there is no union", () => {
const schema = {
type: "object",

View file

@ -481,6 +481,12 @@ export function missingRequiredFields(
!admitsNull(property)
);
}
return value === undefined || value === "";
if (value === "") {
// A branch may pin its discriminator to the empty string, and the
// one-option control cannot produce anything else — reporting the seeded
// value as missing would make that branch permanently uncallable.
return constOf(properties[name]) !== "";
}
return value === undefined;
});
}

View file

@ -2215,6 +2215,51 @@ describe("SchemaForm multiline strings (#2042)", () => {
);
});
it("resets a nested form's own branch when the outer branch changes", async () => {
const user = userEvent.setup();
const nested: InspectorFormSchema = {
type: "object",
properties: {
config: {
type: "object",
title: "Config",
properties: {},
anyOf: [
{
type: "object",
title: "Inner A",
properties: { alpha: { type: "string", title: "Alpha" } },
},
{
type: "object",
title: "Inner B",
properties: { beta: { type: "string", title: "Beta" } },
},
],
},
},
anyOf: [
{ type: "object", title: "Outer A", properties: { x: {} } },
{ type: "object", title: "Outer B", properties: { y: {} } },
],
};
renderWithMantine(
<SchemaForm schema={nested} values={{}} onChange={vi.fn()} />,
);
const [outer, inner] = screen.getAllByRole("textbox", {
name: /Variant/,
});
await user.click(inner!);
await user.click(screen.getByRole("option", { name: "Inner B" }));
expect(screen.getByRole("textbox", { name: /Beta/ })).toBeTruthy();
await user.click(outer!);
await user.click(screen.getByRole("option", { name: "Outer B" }));
// The nested form is still mounted, so only a changed reset key can stop
// it displaying a branch the newly seeded values do not describe.
expect(screen.getByRole("textbox", { name: /Alpha/ })).toBeTruthy();
});
it("renders no picker for a single-branch union but still shows its fields", () => {
const schema: InspectorFormSchema = {
type: "object",

View file

@ -972,8 +972,12 @@ export function SchemaForm({
handleFieldChange(fieldName, nestedValues)
}
disabled={disabled}
// Sub-fields belong to the same entity, so they reset with it.
resetKey={resetKey}
// Sub-fields belong to the same entity, so they reset with it —
// and to the branch it is being edited under, since two outer
// alternatives can both carry this field and a nested form left
// mounted across the switch would keep displaying the nested
// branch chosen for the other one.
resetKey={draftKey}
// A nested form's invalid draft is the outer form's invalid draft,
// so it reports through the same channel under this field's name.
onValidityChange={(nestedInvalid) =>