fix: address Copilot review round 6 — additionalProperties, annotations, prototype-safe records

- rootUnion: decline a union that adds fields under a restrictive root
  `additionalProperties`. The keyword constrains what its SIBLING
  `properties` does not name, so the original schema admits none of the
  branch fields; flattening moves them beside the keyword, where they read as
  allowed and the form would submit what the schema forbids.
- `default` joins the annotation set: it constrains nothing, so two
  declarations suggesting different initial values no longer conflict.
- Property records are read with `Object.hasOwn` and built with
  `fromEntries`/`defineProperty`. `properties` is a JSON record, so
  `constructor` is a legal argument name that `in` reported as a collision
  the root never made, and `__proto__` is one that assignment would drop
  into the legacy prototype setter instead of keeping as a field.
- SchemaForm: a value survives a branch switch unless BOTH branches declare
  the name. A root argument the outgoing branch merely specialized is still a
  root argument in a branch that inherits it, so dropping it erased a valid
  value.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-26 02:56:55 -04:00
commit c15e30414f
3 changed files with 181 additions and 17 deletions

View file

@ -2149,6 +2149,39 @@ describe("SchemaForm multiline strings (#2042)", () => {
expect(onChange).toHaveBeenCalledWith({});
});
it("keeps a root value the incoming branch merely inherits", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
const schema: InspectorFormSchema = {
type: "object",
properties: { count: {} },
anyOf: [
{
type: "object",
title: "A",
properties: { count: { type: "number", title: "Count" } },
},
{
type: "object",
title: "B",
properties: { other: { type: "string", title: "Other" } },
},
],
};
renderWithMantine(
<SchemaForm
schema={schema}
values={{ count: 3 }}
onChange={onChange}
/>,
);
await user.click(screen.getByRole("textbox", { name: /Variant/ }));
await user.click(screen.getByRole("option", { name: "B" }));
// Branch B does not redeclare `count`, so it is a root argument there —
// dropping it would erase a value the schema still accepts.
expect(onChange).toHaveBeenCalledWith({ count: 3 });
});
it("renders no picker for a single-branch union but still shows its fields", () => {
const schema: InspectorFormSchema = {
type: "object",

View file

@ -694,20 +694,23 @@ export function SchemaForm({
if (!nextBranch) return;
setBranchIndex(nextIndex);
const nextProperties = nextBranch.schema.properties ?? {};
// Only what the *base* contributed is carried across — a value the outgoing
// branch declared belongs to that branch's shape, and a name the two
// branches type differently would arrive as the wrong type entirely (a `3`
// typed into branch A's number field landing in branch B's checkbox). A
// field the incoming branch pins to a `const` is likewise not carried: the
// branches of a discriminated union share the discriminator's *name* and
// disagree about its value.
// A value is carried unless it belonged to the outgoing branch's own shape:
// a name **both** branches declare may be typed differently by each, so a
// `3` typed into branch A's number field must not arrive in branch B's
// checkbox. A name only the outgoing branch specialized still survives when
// the incoming branch merely inherits the root's declaration — it is a root
// argument there, and dropping it would erase a valid value. A field the
// incoming branch pins to a `const` is never carried: the branches of a
// discriminated union share the discriminator's *name* and disagree about
// its value.
const outgoing = new Set(activeBranch?.declaredFields ?? []);
const incoming = new Set(nextBranch.declaredFields);
const carried: Record<string, unknown> = {};
for (const [name, fieldSchema] of Object.entries(nextProperties)) {
if (
values[name] !== undefined &&
fieldSchema.const === undefined &&
!outgoing.has(name)
!(outgoing.has(name) && incoming.has(name))
) {
carried[name] = values[name];
}

View file

@ -260,6 +260,83 @@ describe("resolveRootUnion", () => {
expect(branches).toEqual([]);
});
it("tolerates a branch suggesting a different default", () => {
// `default` constrains nothing, so two declarations suggesting different
// initial values still accept the same values.
const { branches } = resolveRootUnion({
type: "object",
properties: { count: { default: 1 } },
anyOf: [
{
type: "object",
properties: { count: { type: "number", default: 2 } },
},
{ type: "object", properties: { other: { type: "string" } } },
],
});
expect(branches).toHaveLength(2);
});
it("does not mistake an inherited object property for a root declaration", () => {
// `constructor` is a legal argument name; finding it on `Object.prototype`
// would report a conflict the root never declared.
const anyOf: unknown[] = [
{ type: "object", properties: { constructor: { type: "string" } } },
{ type: "object", properties: { other: { type: "string" } } },
];
const { branches } = resolveRootUnion({
type: "object",
properties: {},
anyOf,
});
expect(branches).toHaveLength(2);
expect(branches[0].schema.properties?.constructor).toEqual({
type: "string",
});
});
it("keeps a branch property named __proto__", () => {
// Assigning it would invoke the legacy prototype setter rather than create
// an own property, losing a renderable field.
const { branches } = resolveRootUnion({
type: "object",
properties: { keep: { type: "string" } },
// A computed key: `__proto__:` in an object literal is the prototype
// setter, so the literal form would not even create the property.
anyOf: [
{ type: "object", properties: { ["__proto__"]: { type: "string" } } },
{ type: "object", properties: { other: { type: "string" } } },
] as unknown[],
});
expect(Object.keys(branches[0].schema.properties ?? {})).toEqual([
"keep",
"__proto__",
]);
});
it("declines a union that adds fields under a restrictive additionalProperties", () => {
// `additionalProperties` constrains what its SIBLING `properties` does not
// name, so the original schema admits none of the branch fields — moving
// them beside the keyword would make them read as allowed.
const { branches } = resolveRootUnion({
type: "object",
properties: { known: { type: "string" } },
additionalProperties: false,
anyOf: [EMAIL, SMS],
});
expect(branches).toEqual([]);
});
it("allows a restrictive additionalProperties the branches stay within", () => {
const { branches } = resolveRootUnion({
type: "object",
properties: { kind: {}, address: {}, phone: {} },
additionalProperties: false,
anyOf: [EMAIL, SMS],
});
expect(branches).toHaveLength(2);
});
it("declines a union whose branch contradicts the root's type for a field", () => {
// `string` under a base `number` describes a value that cannot exist, so
// flattening it would render one type and accept what the schema rejects.