From c77134a0ae3b1bf75341e8945f76764bb7f65792 Mon Sep 17 00:00:00 2001 From: viniciusventura29 Date: Tue, 10 Nov 2026 17:06:25 -0300 Subject: [PATCH 1/2] fix(sections-editor): show union branch fields inside a deep Flag A multivariate field built its inner form from the variant value schema, which sits 3 levels deeper than plainSchema. Deep in a form, the depth cap leaves the branches of a type-discriminated union in T without a schema, so picking a branch drew no fields. Prefer plainSchema, the same T built outside the depth cap. Co-Authored-By: Claude Opus 5.5 --- .../sections-editor/schema-form.test.ts | 90 ++++++++++++++++++- .../sections-editor/schema-form.tsx | 13 +-- 2 files changed, 96 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/sections-editor/schema-form.test.ts b/apps/web/src/components/sections-editor/schema-form.test.ts index 53d7a5adf9a..3f123991284 100644 --- a/apps/web/src/components/sections-editor/schema-form.test.ts +++ b/apps/web/src/components/sections-editor/schema-form.test.ts @@ -8,7 +8,7 @@ import { ImageField } from "./fields/image-field"; import { BooleanField } from "./fields/boolean-field"; import { NumberField } from "./fields/number-field"; import { StringField } from "./fields/string-field"; -import type { LiveMeta } from "./resolve-schema"; +import type { LiveMeta, SchemaProperty } from "./resolve-schema"; describe("inferBlockRefArrayItemSchema", () => { test("infers string, number, boolean from first item", () => { @@ -208,6 +208,94 @@ describe("renderField – multivariate widget keeps the prop description", () => }); }); +describe("renderField – multivariate object uses the shallower plainSchema", () => { + // `Flag<{ media: Video | Image }>` nested deep (a loader array of flags): the + // variant value is built 3 levels deeper than `plainSchema`, so the depth cap + // leaves the `type`-discriminated branches of `media` without a schema. + const mediaUnion = (withBranchSchemas: boolean): SchemaProperty => ({ + type: "block-ref", + discriminatorKey: "type", + anyOfRefs: [ + { + resolveType: "video", + title: "Video", + discriminatorValue: "video", + schema: withBranchSchemas + ? { + type: "object", + properties: { video: { type: "string", format: "video-uri" } }, + } + : undefined, + }, + { + resolveType: "image", + title: "Image", + discriminatorValue: "image", + schema: withBranchSchemas + ? { + type: "object", + properties: { + image: { type: "string", format: "image-uri" }, + alt: { type: "string" }, + }, + } + : undefined, + }, + ], + }); + const kit = (withBranchSchemas: boolean): SchemaProperty => ({ + type: "object", + properties: { media: mediaUnion(withBranchSchemas) }, + }); + + test("the inner form gets the union branches the variant value lost", () => { + const el = renderField({ + onChange: () => {}, + path: "content", + label: "Content", + value: { media: { type: "image" } }, + schema: { + type: "block-ref", + plainSchema: kit(true), + anyOfRefs: [ + { + resolveType: "site/flags/multivariate/cardContent.ts", + title: "Card Content Variants", + schema: { + type: "object", + properties: { + variants: { + type: "array", + items: { + type: "object", + properties: { value: kit(false) }, + }, + }, + }, + }, + }, + ], + }, + }) as { + props: { renderInnerField: (p: unknown) => unknown }; + } | null; + const inner = el?.props.renderInnerField({ + onChange: () => {}, + path: "content", + label: "Content", + value: { media: { type: "image" } }, + schema: {}, + }) as { props: { schema: SchemaProperty } } | null; + const image = inner?.props.schema.properties?.media?.anyOfRefs?.find( + (r) => r.resolveType === "image", + ); + expect(Object.keys(image?.schema?.properties ?? {})).toEqual([ + "image", + "alt", + ]); + }); +}); + describe("renderField – boolean schema wins over stored value type", () => { const typeOf = (el: unknown) => (el as { type?: unknown } | null)?.type; const baseProps = { diff --git a/apps/web/src/components/sections-editor/schema-form.tsx b/apps/web/src/components/sections-editor/schema-form.tsx index 9fb104d802f..cd733c6cbd2 100644 --- a/apps/web/src/components/sections-editor/schema-form.tsx +++ b/apps/web/src/components/sections-editor/schema-form.tsx @@ -312,15 +312,16 @@ function renderFieldBody(fieldProps: FieldProps): ReactNode { const variantValueSchema = ref.schema?.properties?.variants?.items?.properties?.value; // Determine the schema for the inner (plain) field. Priority: - // 1. variant value schema (when fully resolved and non-circular) - // 2. plainSchema on the outer block-ref (non-loader branch preserved - // during resolution, e.g. { type: "string", format: "image-uri" }) + // 1. plainSchema on the outer block-ref: the same `T` as the variant + // value, built shallower and outside the depth cap. Deep in a form, + // the variant value can lose the branch schemas of a union in `T`. + // 2. variant value schema (when fully resolved and non-circular) // 3. plainSchema on the variant value schema (circular block-ref) const innerSchema = - variantValueSchema && variantValueSchema.type !== "block-ref" + schema.plainSchema ?? + (variantValueSchema && variantValueSchema.type !== "block-ref" ? variantValueSchema - : (schema.plainSchema ?? - variantValueSchema?.plainSchema ?? { type: "string" }); + : (variantValueSchema?.plainSchema ?? { type: "string" })); // The prop's own @description lives on the outer union, not the widget branch. const innerFieldSchema: SchemaProperty = { ...innerSchema, From 510e3f31faefb5b5510b99ba8496902517309b22 Mon Sep 17 00:00:00 2001 From: viniciusventura29 Date: Tue, 10 Nov 2026 20:13:38 -0300 Subject: [PATCH 2/2] fix(sections-editor): fill only the union branches the depth cap emptied plainSchema and the variant value come from separate raw definitions of the same type, and the generator can emit them differently (an inline list in one, a loader picker in the other). Preferring plainSchema wholesale turned such a picker into a list editor. Keep the variant value and take from plainSchema only the type-discriminated branches that resolved without a schema. Co-Authored-By: Claude Opus 5.5 --- .../fields/resolved-schema-cache.test.ts | 88 +++++++++++++++++- .../fields/resolved-schema-cache.ts | 90 +++++++++++++++++++ .../sections-editor/schema-form.test.ts | 2 +- .../sections-editor/schema-form.tsx | 21 +++-- 4 files changed, 190 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/sections-editor/fields/resolved-schema-cache.test.ts b/apps/web/src/components/sections-editor/fields/resolved-schema-cache.test.ts index 93a8f460845..d9db827b1fc 100644 --- a/apps/web/src/components/sections-editor/fields/resolved-schema-cache.test.ts +++ b/apps/web/src/components/sections-editor/fields/resolved-schema-cache.test.ts @@ -1,7 +1,8 @@ -import { describe, expect, it } from "bun:test"; +import { describe, expect, it, test } from "bun:test"; import { cachedResolveSchema, expandRecursiveSchema, + fillDepthCappedBranches, } from "./resolved-schema-cache"; import type { LiveMeta, SchemaProperty } from "../resolve-schema"; @@ -175,3 +176,88 @@ describe("a self-referencing menu, six levels deep (A9: main's behaviour)", () = expect(item?.titleBy).toBe("{{{label}}}"); }); }); + +describe("fillDepthCappedBranches", () => { + const branch = (resolveType: string, schema?: SchemaProperty) => ({ + resolveType, + title: resolveType, + discriminatorValue: resolveType, + schema, + }); + const imageSchema: SchemaProperty = { + type: "object", + properties: { image: { type: "string", format: "image-uri" } }, + }; + const videoSchema: SchemaProperty = { + type: "object", + properties: { video: { type: "string", format: "video-uri" } }, + }; + const media = (withSchemas: boolean): SchemaProperty => ({ + type: "block-ref", + discriminatorKey: "type", + anyOfRefs: [ + branch("image", withSchemas ? imageSchema : undefined), + branch("video", withSchemas ? videoSchema : undefined), + ], + }); + + test("fills discriminated branches the depth cap emptied", () => { + const capped: SchemaProperty = { + type: "object", + properties: { media: media(false) }, + }; + const shallow: SchemaProperty = { + type: "object", + properties: { media: media(true) }, + }; + const filled = fillDepthCappedBranches(capped, shallow); + const refs = filled.properties?.media?.anyOfRefs ?? []; + expect(refs.map((r) => r.schema)).toEqual([imageSchema, videoSchema]); + // Same inputs, same object: the form re-renders without churn. + expect(fillDepthCappedBranches(capped, shallow)).toBe(filled); + }); + + test("keeps what the schema already resolved", () => { + // `Product[]` built as a loader picker in one definition and as an inline + // list in the other: the picker stays. + const picker: SchemaProperty = { + type: "block-ref", + anyOfRefs: [{ resolveType: "site/loaders/products.ts", title: "List" }], + }; + const resolved: SchemaProperty = { + type: "object", + properties: { media: media(true), shelf: picker }, + }; + const other: SchemaProperty = { + type: "object", + properties: { + media: media(true), + shelf: { type: "array", items: { type: "object" } }, + }, + }; + expect(fillDepthCappedBranches(resolved, other)).toBe(resolved); + }); + + test("leaves empty branches of a module union to lazy resolution", () => { + const moduleUnion: SchemaProperty = { + type: "block-ref", + anyOfRefs: [{ resolveType: "site/sections/Hero.tsx", title: "Hero" }], + }; + const shallow: SchemaProperty = { + type: "block-ref", + anyOfRefs: [ + { + resolveType: "site/sections/Hero.tsx", + title: "Hero", + schema: imageSchema, + }, + ], + }; + expect(fillDepthCappedBranches(moduleUnion, shallow)).toBe(moduleUnion); + }); + + test("without a fallback returns the schema", () => { + const schema = media(false); + expect(fillDepthCappedBranches(schema, undefined)).toBe(schema); + }); +}); diff --git a/apps/web/src/components/sections-editor/fields/resolved-schema-cache.ts b/apps/web/src/components/sections-editor/fields/resolved-schema-cache.ts index 2313a33110a..7b35508d667 100644 --- a/apps/web/src/components/sections-editor/fields/resolved-schema-cache.ts +++ b/apps/web/src/components/sections-editor/fields/resolved-schema-cache.ts @@ -70,3 +70,93 @@ export function expandRecursiveSchema( } return expanded; } + +const filledCache = new WeakMap< + SchemaProperty, + WeakMap +>(); + +/** + * `schema` with the `type`-discriminated union branches that the depth cap left + * without a schema taken from `fallback`, the same type built shallower. Such + * branches have no lazy resolution, so without this the branch select draws no + * fields. Only empty branches are filled: what `schema` already has is kept. + */ +export function fillDepthCappedBranches( + schema: SchemaProperty, + fallback: SchemaProperty | undefined, +): SchemaProperty { + if (!fallback || schema === fallback) return schema; + let byFallback = filledCache.get(schema); + let filled = byFallback?.get(fallback); + if (!filled) { + filled = fillBranches(schema, fallback, 0); + if (!byFallback) { + byFallback = new WeakMap(); + filledCache.set(schema, byFallback); + } + byFallback.set(fallback, filled); + } + return filled; +} + +// resolveSchema() output is acyclic; the cap only bounds a malformed tree. +const MAX_FILL_DEPTH = 64; + +function fillBranches( + schema: SchemaProperty, + fallback: SchemaProperty, + depth: number, +): SchemaProperty { + if (depth > MAX_FILL_DEPTH) return schema; + let changed = false; + const fillChild = ( + child: SchemaProperty | undefined, + other: SchemaProperty | undefined, + ) => { + if (!child || !other || child === other) return child; + const next = fillBranches(child, other, depth + 1); + if (next !== child) changed = true; + return next; + }; + + const anyOfRefs = schema.anyOfRefs?.map((ref) => { + const other = fallback.anyOfRefs?.find( + (r) => r.resolveType === ref.resolveType, + ); + if (!other?.schema) return ref; + if (!ref.schema) { + if (!schema.discriminatorKey) return ref; + changed = true; + return { ...ref, schema: other.schema }; + } + const next = fillChild(ref.schema, other.schema); + return next === ref.schema ? ref : { ...ref, schema: next }; + }); + const properties = schema.properties + ? Object.fromEntries( + Object.entries(schema.properties).map(([key, prop]) => [ + key, + fillChild(prop, fallback.properties?.[key]) ?? prop, + ]), + ) + : undefined; + const items = fillChild(schema.items, fallback.items); + const inlineUnionBranches = schema.inlineUnionBranches?.map( + (branch, index) => { + const other = fallback.inlineUnionBranches?.[index]; + if (other?.title !== branch.title) return branch; + const next = fillChild(branch.schema, other.schema); + return next === branch.schema ? branch : { ...branch, schema: next }; + }, + ); + + if (!changed) return schema; + return { + ...schema, + ...(anyOfRefs && { anyOfRefs }), + ...(properties && { properties }), + ...(items && { items }), + ...(inlineUnionBranches && { inlineUnionBranches }), + }; +} diff --git a/apps/web/src/components/sections-editor/schema-form.test.ts b/apps/web/src/components/sections-editor/schema-form.test.ts index 3f123991284..9d07b35922d 100644 --- a/apps/web/src/components/sections-editor/schema-form.test.ts +++ b/apps/web/src/components/sections-editor/schema-form.test.ts @@ -208,7 +208,7 @@ describe("renderField – multivariate widget keeps the prop description", () => }); }); -describe("renderField – multivariate object uses the shallower plainSchema", () => { +describe("renderField – multivariate object fills depth-capped union branches", () => { // `Flag<{ media: Video | Image }>` nested deep (a loader array of flags): the // variant value is built 3 levels deeper than `plainSchema`, so the depth cap // leaves the `type`-discriminated branches of `media` without a schema. diff --git a/apps/web/src/components/sections-editor/schema-form.tsx b/apps/web/src/components/sections-editor/schema-form.tsx index cd733c6cbd2..1a10d71861c 100644 --- a/apps/web/src/components/sections-editor/schema-form.tsx +++ b/apps/web/src/components/sections-editor/schema-form.tsx @@ -24,7 +24,10 @@ import { InlineUnionField } from "./fields/inline-union-field"; import { LocationField } from "./fields/location-field"; import { MapField } from "./fields/map-field"; import { MultivariateFieldWrapper } from "./fields/multivariate-field-wrapper"; -import { expandRecursiveSchema } from "./fields/resolved-schema-cache"; +import { + expandRecursiveSchema, + fillDepthCappedBranches, +} from "./fields/resolved-schema-cache"; import { isSecretBlock } from "@decocms/shared/decofile"; import { isSecretField } from "./fields/secret-field"; import { SecretFieldForBackend } from "./fields/protocol-secret-field"; @@ -312,16 +315,16 @@ function renderFieldBody(fieldProps: FieldProps): ReactNode { const variantValueSchema = ref.schema?.properties?.variants?.items?.properties?.value; // Determine the schema for the inner (plain) field. Priority: - // 1. plainSchema on the outer block-ref: the same `T` as the variant - // value, built shallower and outside the depth cap. Deep in a form, - // the variant value can lose the branch schemas of a union in `T`. - // 2. variant value schema (when fully resolved and non-circular) + // 1. variant value schema (when fully resolved and non-circular), with + // the union branches its depth cap emptied taken from plainSchema + // 2. plainSchema on the outer block-ref (non-loader branch preserved + // during resolution, e.g. { type: "string", format: "image-uri" }) // 3. plainSchema on the variant value schema (circular block-ref) const innerSchema = - schema.plainSchema ?? - (variantValueSchema && variantValueSchema.type !== "block-ref" - ? variantValueSchema - : (variantValueSchema?.plainSchema ?? { type: "string" })); + variantValueSchema && variantValueSchema.type !== "block-ref" + ? fillDepthCappedBranches(variantValueSchema, schema.plainSchema) + : (schema.plainSchema ?? + variantValueSchema?.plainSchema ?? { type: "string" }); // The prop's own @description lives on the outer union, not the widget branch. const innerFieldSchema: SchemaProperty = { ...innerSchema,