fix(ui): preserve mixed schema union values (#116602)

This commit is contained in:
Vincent Koc
2026-07-31 11:15:21 +08:00
committed by GitHub
parent 16c23377d0
commit 7bc216394a
3 changed files with 156 additions and 0 deletions
@@ -0,0 +1,128 @@
import { Value } from "typebox/value";
import { describe, expect, it } from "vitest";
import { analyzeConfigSchema } from "../../ui/src/components/config-form.analyze.js";
import type { JsonSchema } from "../../ui/src/components/config-form.shared.js";
import { isSupportedConfigValueValid } from "../../ui/src/components/config-form.validation.js";
import { computeBaseConfigSchemaResponse } from "./schema-base.js";
type MixedUnion = {
path: string;
schema: JsonSchema;
literals: unknown[];
};
function pathKey(path: string[]): string {
return path.join(".");
}
function unionLiterals(schema: JsonSchema): unknown[] {
const union = schema.anyOf ?? schema.oneOf;
if (!union) {
return [];
}
return union.flatMap((entry) => {
if (Array.isArray(entry.enum)) {
return entry.enum;
}
if (Object.hasOwn(entry, "const")) {
return [entry.const];
}
return entry.type === "null" ? [null] : [];
});
}
function isLiteralOnlyBranch(schema: JsonSchema): boolean {
return Array.isArray(schema.enum) || Object.hasOwn(schema, "const") || schema.type === "null";
}
function collectMixedUnions(
schema: JsonSchema,
path: string[] = [],
result: MixedUnion[] = [],
): MixedUnion[] {
const union = schema.anyOf ?? schema.oneOf;
const literals = unionLiterals(schema);
if (union && literals.length > 0 && union.some((entry) => !isLiteralOnlyBranch(entry))) {
result.push({ path: pathKey(path) || "<root>", schema, literals });
}
for (const [key, child] of Object.entries(schema.properties ?? {})) {
collectMixedUnions(child, [...path, key], result);
}
const items = schema.items;
if (Array.isArray(items)) {
items.forEach((item, index) => collectMixedUnions(item, [...path, String(index)], result));
} else if (items) {
collectMixedUnions(items, [...path, "*"], result);
}
if (schema.additionalProperties && typeof schema.additionalProperties === "object") {
collectMixedUnions(schema.additionalProperties, [...path, "*"], result);
}
for (const branch of [
...(schema.allOf ?? []),
...(schema.anyOf ?? []),
...(schema.oneOf ?? []),
]) {
collectMixedUnions(branch, path, result);
}
return result;
}
function schemaAtPath(schema: JsonSchema, path: string): JsonSchema | undefined {
let current: JsonSchema | undefined = schema;
for (const segment of path === "<root>" ? [] : path.split(".")) {
if (!current) {
return undefined;
}
if (segment === "*") {
current = Array.isArray(current.items)
? undefined
: current.items ||
(current.additionalProperties && typeof current.additionalProperties === "object"
? current.additionalProperties
: undefined);
continue;
}
if (/^\d+$/u.test(segment) && Array.isArray(current.items)) {
current = current.items[Number(segment)];
continue;
}
current = current.properties?.[segment];
}
return current;
}
function isPathUnsupported(path: string, unsupportedPaths: string[]): boolean {
return unsupportedPaths.some(
(unsupportedPath) =>
unsupportedPath === "<root>" ||
path === unsupportedPath ||
path.startsWith(`${unsupportedPath}.`),
);
}
describe("generated config schema Control UI contract", () => {
it("never drops accepted literals from mixed unions", () => {
const rawSchema = computeBaseConfigSchemaResponse({
generatedAt: "control-ui-contract",
}).schema as JsonSchema;
const analysis = analyzeConfigSchema(rawSchema);
const mixedUnions = collectMixedUnions(rawSchema);
expect(mixedUnions.length).toBeGreaterThan(0);
const lossyPaths = mixedUnions.flatMap(({ path, schema, literals }) => {
if (isPathUnsupported(path, analysis.unsupportedPaths)) {
return [];
}
const analyzedSchema = analysis.schema ? schemaAtPath(analysis.schema, path) : undefined;
return literals
.filter((literal) => Value.Check(schema as never, literal))
.filter(
(literal) => !analyzedSchema || !isSupportedConfigValueValid(analyzedSchema, literal),
)
.map(() => path);
});
expect([...new Set(lossyPaths)].toSorted()).toEqual([]);
});
});
@@ -37,6 +37,28 @@ describe("config form composition integrity", () => {
expect(unsupportedUnion.unsupportedPaths).toEqual(["mixed"]);
});
it("keeps literal and typed unions in Raw mode", () => {
const analysis = analyzeConfigSchema({
type: "object",
properties: {
retention: {
anyOf: [{ type: "string" }, { const: false }],
},
mode: {
oneOf: [{ type: "boolean" }, { enum: ["auto", "manual"] }],
},
},
});
expect(analysis.unsupportedPaths).toEqual(["retention", "mode"]);
expect(analysis.schema?.properties?.retention).toMatchObject({
anyOf: [{ type: "string" }, { const: false }],
});
expect(analysis.schema?.properties?.mode).toMatchObject({
oneOf: [{ type: "boolean" }, { enum: ["auto", "manual"] }],
});
});
it("marks required-only object branches as form-unsafe", () => {
const analysis = analyzeConfigSchema({
type: "object",
+6
View File
@@ -606,6 +606,12 @@ function normalizeUnion(
};
}
// A native field cannot preserve both literal sentinels and an open typed branch.
// Keep the original union for Raw mode instead of silently dropping valid values.
if (literals.length > 0 && remaining.length > 0) {
return null;
}
if (remaining.length === 1) {
const remainingSchema = remaining[0];
if (!remainingSchema) {