Merge pull request #2027 from modelcontextprotocol/v2/fix/2026-default-snapback
fix(web): stop a schema default from reverting the edit in progress
This commit is contained in:
commit
3eeae10fbc
2 changed files with 211 additions and 7 deletions
|
|
@ -1648,3 +1648,145 @@ describe("JSON editor escaping (#1853, #1856, #1885)", () => {
|
||||||
expect(jsonInput.value).not.toContain("\\");
|
expect(jsonInput.value).not.toContain("\\");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A schema `default` is what a field *opens* with, not a value re-imposed on
|
||||||
|
// every unsendable draft. Unsendable text reports `undefined` by design, and
|
||||||
|
// `resolveValue` used to turn that straight back into the default — a change
|
||||||
|
// from the previous value, so the draft/value re-sync rewrote the box and
|
||||||
|
// reverted the keystroke.
|
||||||
|
//
|
||||||
|
// The two halves differed in how reachable they were, which the tests below
|
||||||
|
// mirror deliberately:
|
||||||
|
//
|
||||||
|
// - **The number field reverted in the app.** Numbers compare by value, so
|
||||||
|
// clearing a defaulted box (value `3` → resolved default `30`) always fired
|
||||||
|
// the re-sync and refilled itself.
|
||||||
|
// - **The JSON field was latent.** `collectSchemaDefaults` assigns
|
||||||
|
// `fieldSchema.default` itself, so a field seeded from the schema holds the
|
||||||
|
// *same reference* the substitution returns and nothing fires. It needs a
|
||||||
|
// structurally-equal but distinct value object to become observable — which
|
||||||
|
// is what parsed wire/deep-link values and rebuilt nested-object defaults
|
||||||
|
// produce, and what these tests construct.
|
||||||
|
describe("SchemaForm defaulted fields (#2026)", () => {
|
||||||
|
function DefaultHarness({
|
||||||
|
schema,
|
||||||
|
initial = {},
|
||||||
|
}: {
|
||||||
|
schema: InspectorFormSchema;
|
||||||
|
initial?: Record<string, unknown>;
|
||||||
|
}) {
|
||||||
|
const [values, setValues] = useState<Record<string, unknown>>(initial);
|
||||||
|
return <SchemaForm schema={schema} values={values} onChange={setValues} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const arrayWithDefault: InspectorFormSchema = {
|
||||||
|
type: "object",
|
||||||
|
properties: { tags: { type: "array", title: "Tags", default: ["a"] } },
|
||||||
|
};
|
||||||
|
|
||||||
|
it("opens the JSON editor on the schema default", () => {
|
||||||
|
renderWithMantine(<DefaultHarness schema={arrayWithDefault} />);
|
||||||
|
expect((screen.getByLabelText(/Tags/) as HTMLTextAreaElement).value).toBe(
|
||||||
|
'[\n "a"\n]',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a keystroke typed into a defaulted JSON field", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
// Seeded with a value that is equal to the schema default but is not the
|
||||||
|
// same object — how values parsed off the wire or out of a deep link
|
||||||
|
// arrive. Sharing the reference (what `collectSchemaDefaults` produces) is
|
||||||
|
// what keeps this latent rather than what makes it safe.
|
||||||
|
renderWithMantine(
|
||||||
|
<DefaultHarness schema={arrayWithDefault} initial={{ tags: ["a"] }} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
const jsonInput = screen.getByLabelText(/Tags/) as HTMLTextAreaElement;
|
||||||
|
await user.click(jsonInput);
|
||||||
|
await user.keyboard("{End}x");
|
||||||
|
|
||||||
|
// The invalid draft is the user's, not the default reasserting itself.
|
||||||
|
expect(jsonInput.value).toBe('[\n "a"\n]x');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets a defaulted JSON field be edited to a new value", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
// Distinct-object seed again, for the reason above.
|
||||||
|
renderWithMantine(
|
||||||
|
<DefaultHarness schema={arrayWithDefault} initial={{ tags: ["a"] }} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
const jsonInput = screen.getByLabelText(/Tags/) as HTMLTextAreaElement;
|
||||||
|
await user.clear(jsonInput);
|
||||||
|
await user.type(jsonInput, '[["b"]');
|
||||||
|
|
||||||
|
expect(jsonInput.value).toBe('["b"]');
|
||||||
|
expect(screen.queryByText(/Not valid JSON/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets a defaulted number field be emptied", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const schema: InspectorFormSchema = {
|
||||||
|
type: "object",
|
||||||
|
properties: { n: { type: "integer", title: "N", default: 30 } },
|
||||||
|
};
|
||||||
|
renderWithMantine(<DefaultHarness schema={schema} initial={{ n: 30 }} />);
|
||||||
|
|
||||||
|
const n = screen.getByLabelText(/N/) as HTMLInputElement;
|
||||||
|
expect(n.value).toBe("30");
|
||||||
|
|
||||||
|
await user.click(n);
|
||||||
|
await user.keyboard("{End}{Backspace}{Backspace}");
|
||||||
|
|
||||||
|
// The box stays empty instead of refilling itself with the default.
|
||||||
|
expect(n.value).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
// An explicit `null` is a value, not an absent one, so a non-null default
|
||||||
|
// must not be substituted for it. Note the field itself never emits `null` —
|
||||||
|
// clearing it reports `undefined` (pinned by "passes undefined to onChange
|
||||||
|
// when a number field is cleared") — so this arrives from parent state: a
|
||||||
|
// value received from the server, restored from a deep link, or written by a
|
||||||
|
// caller for a nullable schema.
|
||||||
|
it("shows an explicit null as empty, not as the default", () => {
|
||||||
|
const schema: InspectorFormSchema = {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
n: {
|
||||||
|
title: "N",
|
||||||
|
anyOf: [{ type: "integer" }, { type: "null" }],
|
||||||
|
default: 30,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
renderWithMantine(<DefaultHarness schema={schema} initial={{ n: null }} />);
|
||||||
|
|
||||||
|
expect((screen.getByLabelText(/^N$/) as HTMLInputElement).value).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still re-syncs a defaulted field when the value changes externally", async () => {
|
||||||
|
function ExternalHarness() {
|
||||||
|
const [values, setValues] = useState<Record<string, unknown>>({
|
||||||
|
tags: ["a"],
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SchemaForm
|
||||||
|
schema={arrayWithDefault}
|
||||||
|
values={values}
|
||||||
|
onChange={setValues}
|
||||||
|
/>
|
||||||
|
<button type="button" onClick={() => setValues({ tags: ["z"] })}>
|
||||||
|
load example
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithMantine(<ExternalHarness />);
|
||||||
|
|
||||||
|
const jsonInput = screen.getByLabelText(/Tags/) as HTMLTextAreaElement;
|
||||||
|
await user.click(screen.getByRole("button", { name: "load example" }));
|
||||||
|
expect(jsonInput.value).toBe('[\n "z"\n]');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -207,7 +207,14 @@ interface SchemaJsonFieldProps {
|
||||||
description?: string;
|
description?: string;
|
||||||
withAsterisk: boolean;
|
withAsterisk: boolean;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
|
/**
|
||||||
|
* The field's own entry in the form's values — **not** run through
|
||||||
|
* `resolveValue`. A draft-holding field is handed the raw value and the
|
||||||
|
* schema default separately; see `defaultValue` for why.
|
||||||
|
*/
|
||||||
value: unknown;
|
value: unknown;
|
||||||
|
/** The schema `default`, used to seed the draft only. See `defaultValue`. */
|
||||||
|
defaultValue: unknown;
|
||||||
onChange: (value: unknown) => void;
|
onChange: (value: unknown) => void;
|
||||||
onValidityChange: DraftValidityReporter;
|
onValidityChange: DraftValidityReporter;
|
||||||
}
|
}
|
||||||
|
|
@ -244,18 +251,39 @@ interface SchemaJsonFieldProps {
|
||||||
function SchemaJsonField({
|
function SchemaJsonField({
|
||||||
fieldName,
|
fieldName,
|
||||||
value,
|
value,
|
||||||
|
defaultValue,
|
||||||
onChange,
|
onChange,
|
||||||
onValidityChange,
|
onValidityChange,
|
||||||
...inputProps
|
...inputProps
|
||||||
}: SchemaJsonFieldProps) {
|
}: SchemaJsonFieldProps) {
|
||||||
const [draft, setDraft] = useState(() =>
|
const [draft, setDraft] = useState(() => {
|
||||||
value === undefined ? "" : serializeJson(value),
|
const initial = value === undefined ? defaultValue : value;
|
||||||
);
|
return initial === undefined ? "" : serializeJson(initial);
|
||||||
|
});
|
||||||
|
|
||||||
// Re-sync only when the parent's value genuinely diverges from what the draft
|
// Re-sync only when the parent's value genuinely diverges from what the draft
|
||||||
// parses to, which leaves an external reset (a cleared form, a loaded
|
// parses to, which leaves an external reset (a cleared form, a loaded
|
||||||
// example) working while an in-progress `[` — whose parse is `undefined`,
|
// example) working while an in-progress `[` — whose parse is `undefined`,
|
||||||
// matching the `undefined` we just emitted — is left alone.
|
// matching the `undefined` we just emitted — is left alone.
|
||||||
|
//
|
||||||
|
// The value compared here is the field's *raw* entry, never `resolveValue`'s
|
||||||
|
// default substitution (#2026). Unsendable text reports `undefined`, and a
|
||||||
|
// defaulted field resolves that straight back to its default — a change from
|
||||||
|
// the previous value, so comparing against the resolved one would rewrite the
|
||||||
|
// draft to the default and revert the keystroke. Almost every edit passes
|
||||||
|
// through an unsendable state, so a defaulted array or object argument would
|
||||||
|
// be uneditable.
|
||||||
|
//
|
||||||
|
// Would be, not was: this is latent under today's seeding, where
|
||||||
|
// `collectSchemaDefaults` assigns `fieldSchema.default` itself, so the
|
||||||
|
// substitution returns the *same reference* and `useValueChange` never fires.
|
||||||
|
// It goes live as soon as the value is a structurally-equal but distinct
|
||||||
|
// object — parsed from the wire or a deep link, or a nested-object default,
|
||||||
|
// which `collectSchemaDefaults` rebuilds per call. `SchemaNumberInput` has no
|
||||||
|
// such accidental protection, since a number compares by value.
|
||||||
|
//
|
||||||
|
// Either way the default belongs to what the field *opens* with, seeded
|
||||||
|
// above, not to what is re-imposed while it is being typed into.
|
||||||
useValueChange(value, (next) => {
|
useValueChange(value, (next) => {
|
||||||
if (!isSameJson(parseJsonDraft(draft), next)) {
|
if (!isSameJson(parseJsonDraft(draft), next)) {
|
||||||
setDraft(next === undefined ? "" : serializeJson(next));
|
setDraft(next === undefined ? "" : serializeJson(next));
|
||||||
|
|
@ -309,7 +337,17 @@ interface SchemaNumberInputProps {
|
||||||
description?: string;
|
description?: string;
|
||||||
withAsterisk: boolean;
|
withAsterisk: boolean;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
value: number | undefined;
|
/**
|
||||||
|
* Raw, not `resolveValue`-substituted — see `SchemaJsonFieldProps.value`.
|
||||||
|
*
|
||||||
|
* `null` is admitted alongside `undefined` because a nullable number schema
|
||||||
|
* produces it — by way of parent state, never by clearing the box — and the
|
||||||
|
* two are not interchangeable here: `undefined` means "no value supplied"
|
||||||
|
* and takes `defaultValue`, while `null` is a value and displays empty.
|
||||||
|
*/
|
||||||
|
value: number | null | undefined;
|
||||||
|
/** The schema `default`, used to seed the draft only (#2026). May be `null`. */
|
||||||
|
defaultValue: number | null | undefined;
|
||||||
min?: number;
|
min?: number;
|
||||||
max?: number;
|
max?: number;
|
||||||
allowDecimal: boolean;
|
allowDecimal: boolean;
|
||||||
|
|
@ -343,12 +381,29 @@ interface SchemaNumberInputProps {
|
||||||
function SchemaNumberInput({
|
function SchemaNumberInput({
|
||||||
fieldName,
|
fieldName,
|
||||||
value,
|
value,
|
||||||
|
defaultValue,
|
||||||
onChange,
|
onChange,
|
||||||
onValidityChange,
|
onValidityChange,
|
||||||
...inputProps
|
...inputProps
|
||||||
}: SchemaNumberInputProps) {
|
}: SchemaNumberInputProps) {
|
||||||
const [draft, setDraft] = useState<string | number>(value ?? "");
|
// `undefined` means "no value supplied", which is what the default is for.
|
||||||
|
// An explicit `null` is a value, so it must not be overwritten by a non-null
|
||||||
|
// default; it displays as the empty box `resolveValue` produced before.
|
||||||
|
// Hence the `undefined` test rather than `??`, matching `SchemaJsonField`.
|
||||||
|
//
|
||||||
|
// Clearing the box does *not* produce that `null` — `toNumericValue("")`
|
||||||
|
// reports `undefined`, which is the behavior the "passes undefined to
|
||||||
|
// onChange when a number field is cleared" test pins. A `null` reaches this
|
||||||
|
// field only from parent state: a value received from the server, restored
|
||||||
|
// from a deep link, or written by a caller for a nullable schema.
|
||||||
|
const [draft, setDraft] = useState<string | number>(
|
||||||
|
(value === undefined ? defaultValue : value) ?? "",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Compared against the raw value, never the default-substituted one, for the
|
||||||
|
// reason spelled out on `SchemaJsonField` (#2026): clearing this box reports
|
||||||
|
// no value, which a defaulted field resolved straight back to its default —
|
||||||
|
// so the box refilled itself and the argument could not be emptied.
|
||||||
useValueChange(value, (next) => {
|
useValueChange(value, (next) => {
|
||||||
if (!Object.is(toNumericValue(draft), next)) {
|
if (!Object.is(toNumericValue(draft), next)) {
|
||||||
setDraft(next ?? "");
|
setDraft(next ?? "");
|
||||||
|
|
@ -584,7 +639,12 @@ export function SchemaForm({
|
||||||
description={description}
|
description={description}
|
||||||
withAsterisk={isRequired}
|
withAsterisk={isRequired}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
value={rawValue as number | undefined}
|
// Raw and default kept apart, not pre-resolved (#2026). Both admit
|
||||||
|
// `null`, which a nullable number schema really produces.
|
||||||
|
value={values[fieldName] as number | null | undefined}
|
||||||
|
defaultValue={
|
||||||
|
getDefaultValue(fieldSchema) as number | null | undefined
|
||||||
|
}
|
||||||
min={fieldSchema.minimum}
|
min={fieldSchema.minimum}
|
||||||
max={fieldSchema.maximum}
|
max={fieldSchema.maximum}
|
||||||
// An `integer` field rejects the decimal point outright rather than
|
// An `integer` field rejects the decimal point outright rather than
|
||||||
|
|
@ -692,7 +752,9 @@ export function SchemaForm({
|
||||||
description={description}
|
description={description}
|
||||||
withAsterisk={isRequired}
|
withAsterisk={isRequired}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
value={rawValue}
|
// Raw and default kept apart, not pre-resolved (#2026).
|
||||||
|
value={values[fieldName]}
|
||||||
|
defaultValue={getDefaultValue(fieldSchema)}
|
||||||
onChange={(val) => handleFieldChange(fieldName, val)}
|
onChange={(val) => handleFieldChange(fieldName, val)}
|
||||||
onValidityChange={reportFieldValidity}
|
onValidityChange={reportFieldValidity}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue