fix: render nullable enum fields as a Select instead of a raw JSON textarea

A tool argument that is optional AND explicitly nullable — Zod's
`.nullish()` / `.nullable().optional()` — compiles to
`anyOf: [{ type: "string", enum: [...] }, { type: "null" }]`. Every branch
of the tool-call form's field dispatcher tests a single `type` string, so
such a field matched nothing and fell through to the raw-JSON fallback,
whose onChange stores the unparseable in-progress text back into state and
re-`JSON.stringify`s it on the next render — compounding a fresh layer of
escaping on every keystroke.

`normalizeUnionType` already existed for the FastMCP `string|null` shape,
but it only copied the surviving branch's `type` and dropped everything
else, so the `enum` never reached the dispatcher; and nothing called it on
a tool input schema — only elicitation requests, via
`resolveRefsInMessage`. So:

- Rewrite `normalizeUnionType` to hoist the non-null branch's own keywords
  (`enum`, `items`, `properties`, `minimum`, …) onto the flattened schema,
  as v1.x did. This also collapses nine near-identical blocks into two and
  picks up the `object`/`array` cases the `type: [X, "null"]` form was
  missing.
- Normalize each field in `SchemaForm.renderField`, so tool, elicitation,
  and app forms all get it rather than just elicitation.
- Give a nullable enum `Select` a clear affordance, so a value can be set
  back to `null`; a non-nullable enum keeps none, since it would reject it.

Adds a `record_shipment` preset and `nullable-fields-http.json` showcase
config serving all four nullable scalar shapes, for manual verification.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-15 16:10:17 -04:00
commit 937f1dd51b
2 changed files with 428 additions and 84 deletions

View file

@ -811,3 +811,179 @@ describe("SchemaForm", () => {
expect(container.firstChild).not.toBeNull();
});
});
// #1928: "optional AND explicitly nullable" (Zod's `.nullish()`) compiles to a
// nullable union rather than a plain type. Before the normalization step these
// matched no branch and fell through to the raw-JSON fallback, where every
// keystroke re-escaped the value.
describe("SchemaForm nullable unions", () => {
it("renders a Select for an anyOf string-enum|null field", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
const schema: InspectorFormSchema = {
type: "object",
properties: {
type: {
title: "Type",
anyOf: [
{ type: "string", enum: ["envio", "recebimento"] },
{ type: "null" },
],
},
},
};
renderWithMantine(
<SchemaForm schema={schema} values={{}} onChange={onChange} />,
);
await user.click(screen.getByRole("textbox", { name: "Type" }));
await user.click(
await screen.findByRole("option", { name: "envio", hidden: true }),
);
expect(onChange).toHaveBeenCalledWith({ type: "envio" });
});
it("clears a nullable enum back to null", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
const schema: InspectorFormSchema = {
type: "object",
properties: {
type: {
title: "Type",
anyOf: [{ type: "string", enum: ["envio"] }, { type: "null" }],
},
},
};
renderWithMantine(
<SchemaForm
schema={schema}
values={{ type: "envio" }}
onChange={onChange}
/>,
);
// Mantine marks its combobox clear button `aria-hidden` (it is mouse-only,
// `tabIndex={-1}`), so it is only reachable with `hidden: true`.
await user.click(screen.getByRole("button", { hidden: true }));
expect(onChange).toHaveBeenCalledWith({ type: null });
});
it("offers no clear affordance on a non-nullable enum", () => {
const schema: InspectorFormSchema = {
type: "object",
properties: {
format: { type: "string", title: "Format", enum: ["json"] },
},
};
renderWithMantine(
<SchemaForm
schema={schema}
values={{ format: "json" }}
onChange={vi.fn()}
/>,
);
expect(
screen.queryByRole("button", { hidden: true }),
).not.toBeInTheDocument();
});
it("renders a TextInput for a type: [string, null] field", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
const schema: InspectorFormSchema = {
type: "object",
properties: {
note: { type: ["string", "null"], title: "Note" },
},
};
renderWithMantine(
<SchemaForm schema={schema} values={{}} onChange={onChange} />,
);
await user.type(screen.getByLabelText(/Note/), "a");
expect(onChange).toHaveBeenCalledWith({ note: "a" });
});
it("renders a checkbox for an anyOf boolean|null field", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
const schema: InspectorFormSchema = {
type: "object",
properties: {
enabled: {
title: "Enabled",
anyOf: [{ type: "boolean" }, { type: "null" }],
},
},
};
renderWithMantine(
<SchemaForm schema={schema} values={{}} onChange={onChange} />,
);
await user.click(screen.getByLabelText("Enabled"));
expect(onChange).toHaveBeenCalledWith({ enabled: true });
});
it("renders a MultiSelect for an anyOf array-of-enum|null field", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
const schema: InspectorFormSchema = {
type: "object",
properties: {
tags: {
title: "Tags",
anyOf: [
{ type: "array", items: { type: "string", enum: ["a", "b"] } },
{ type: "null" },
],
},
},
};
renderWithMantine(
<SchemaForm schema={schema} values={{}} onChange={onChange} />,
);
await user.click(screen.getByRole("textbox", { name: "Tags" }));
await user.click(
await screen.findByRole("option", { name: "a", hidden: true }),
);
expect(onChange).toHaveBeenCalledWith({ tags: ["a"] });
});
it("renders nested fields for an anyOf object|null field", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
const schema: InspectorFormSchema = {
type: "object",
properties: {
profile: {
title: "Profile",
anyOf: [
{
type: "object",
properties: { nick: { type: "string", title: "Nick" } },
},
{ type: "null" },
],
},
},
};
renderWithMantine(
<SchemaForm schema={schema} values={{}} onChange={onChange} />,
);
await user.type(screen.getByLabelText(/Nick/), "z");
expect(onChange).toHaveBeenCalledWith({ profile: { nick: "z" } });
});
it("still falls back to the JSON input for a union of two real types", () => {
const schema: InspectorFormSchema = {
type: "object",
properties: {
mixed: {
title: "Mixed",
anyOf: [{ type: "string" }, { type: "number" }],
},
},
};
renderWithMantine(
<SchemaForm schema={schema} values={{}} onChange={vi.fn()} />,
);
expect(screen.getByLabelText(/Mixed/).tagName).toBe("TEXTAREA");
});
});

View file

@ -12,6 +12,7 @@ import { useState } from "react";
import { ClearButton } from "../../elements/ClearButton/ClearButton";
import { useValueChange } from "../../../hooks/useValueChange";
import type { InspectorFormSchema } from "../../../utils/jsonUtils";
import { normalizeUnionType } from "../../../utils/schemaUtils";
const FieldLabel = Text.withProps({
fw: 500,
@ -51,6 +52,16 @@ function toEnumData(
return values;
}
/**
* Whether an enum `Select` should offer a clear affordance. Only a schema that
* admits `null` does: clearing emits `null`, and an enum without a null branch
* would reject it. Without this a nullable enum is a one-way door — once a
* value is picked there is no way back to "no answer".
*/
function isClearable(fieldSchema: InspectorFormSchema): boolean {
return fieldSchema.nullable === true;
}
/**
* Interpret whatever Mantine's `NumberInput` reported as the JSON value for the
* field. Anything that is not a finite number becomes `undefined`, which is how
@ -207,7 +218,13 @@ export function SchemaForm({
onChange({ ...values, [fieldName]: fieldValue });
}
function renderField(fieldName: string, fieldSchema: InspectorFormSchema) {
function renderField(fieldName: string, rawSchema: InspectorFormSchema) {
// Flatten a nullable union (`anyOf: [X, {type:"null"}]`, `type: [X,"null"]`)
// before dispatching. Every branch below tests a single `type` string, so
// without this an "optional and explicitly nullable" field — what Zod's
// `.nullish()` emits — matches nothing and falls through to the raw-JSON
// fallback, which is unusable for a value the user has to type (#1928).
const fieldSchema = normalizeUnionType(rawSchema);
const isRequired = requiredFields.includes(fieldName);
const label = fieldSchema.title ?? fieldName;
const description = fieldSchema.description;
@ -223,6 +240,7 @@ export function SchemaForm({
withAsterisk={isRequired}
disabled={disabled}
data={toEnumData(fieldSchema.enum, fieldSchema.enumNames)}
clearable={isClearable(fieldSchema)}
value={(rawValue as string) ?? null}
onChange={(val) => handleFieldChange(fieldName, val)}
/>
@ -243,6 +261,7 @@ export function SchemaForm({
withAsterisk={isRequired}
disabled={disabled}
data={data}
clearable={isClearable(fieldSchema)}
value={(rawValue as string) ?? null}
onChange={(val) => handleFieldChange(fieldName, val)}
/>