From a69cb58451499ec99c75fd23688a4634005d7760 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 26 Aug 2026 04:36:13 -0400 Subject: [PATCH] =?UTF-8?q?fix:=20address=20Copilot=20review=20round=2011?= =?UTF-8?q?=20=E2=80=94=20recurse=20constants=20into=20nested=20objects?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit applySchemaConstants only corrected top-level pinned fields, but the deep link's overlay replaces a nested object wholesale rather than merging into it — so `{ config: { kind: "sms" } }` slipped past a nested read-only `kind: { const: "email" }` the form was displaying. It now recurses into object-valued properties, resolving each one's own root union the same way. Signed-off-by: cliffhall --- clients/web/src/utils/jsonUtils.test.ts | 21 +++++++++++++++ clients/web/src/utils/jsonUtils.ts | 36 +++++++++++++++++++------ 2 files changed, 49 insertions(+), 8 deletions(-) diff --git a/clients/web/src/utils/jsonUtils.test.ts b/clients/web/src/utils/jsonUtils.test.ts index 54c2fa25..ff8bf1b3 100644 --- a/clients/web/src/utils/jsonUtils.test.ts +++ b/clients/web/src/utils/jsonUtils.test.ts @@ -445,6 +445,27 @@ describe("root composition (#2123)", () => { }); }); + it("re-applies a nested object's constants", () => { + // The overlay replaces the whole nested object rather than merging into it, + // so a link naming `{ config: { kind: "sms" } }` would otherwise slip past + // the pinned `kind` the nested form displays. + const schema: InspectorFormSchema = { + type: "object", + properties: { + config: { + type: "object", + properties: { + kind: { type: "string", const: "email" }, + to: { type: "string" }, + }, + }, + }, + }; + expect( + applySchemaConstants(schema, { config: { kind: "sms", to: "a@b.c" } }), + ).toEqual({ config: { kind: "email", to: "a@b.c" } }); + }); + it("leaves values alone when nothing is pinned", () => { const values = { a: 1 }; expect( diff --git a/clients/web/src/utils/jsonUtils.ts b/clients/web/src/utils/jsonUtils.ts index 4145e4a9..a60387a9 100644 --- a/clients/web/src/utils/jsonUtils.ts +++ b/clients/web/src/utils/jsonUtils.ts @@ -194,14 +194,34 @@ export function applySchemaConstants( const { base, branches } = resolveRootUnion(schema); const selected = selectBranchIndex(branches, values) ?? 0; const properties = (branches[selected]?.schema ?? base).properties ?? {}; - const pinned = Object.entries(properties).filter( - ([, fieldSchema]) => fieldSchema.const !== undefined, - ); - if (pinned.length === 0) return values; - return Object.fromEntries([ - ...Object.entries(values), - ...pinned.map(([name, fieldSchema]) => [name, fieldSchema.const]), - ]); + + const corrections: [string, unknown][] = []; + for (const [name, rawSchema] of Object.entries(properties)) { + const fieldSchema = normalizeNullableUnion(rawSchema); + if (fieldSchema.const !== undefined) { + corrections.push([name, fieldSchema.const]); + continue; + } + // Recurse: a nested object renders its own read-only fields, and the + // overlay replaces the whole object rather than merging into it — so a + // link naming `{ config: { kind: "sms" } }` would otherwise slip a value + // past the pinned `kind` the nested form is displaying. + const nested = values[name]; + if ( + typeof nested === "object" && + nested !== null && + !Array.isArray(nested) + ) { + const corrected = applySchemaConstants( + fieldSchema, + nested as Record, + ); + if (corrected !== nested) corrections.push([name, corrected]); + } + } + + if (corrections.length === 0) return values; + return Object.fromEntries([...Object.entries(values), ...corrections]); } /**