fix: address Copilot review round 12 — prototype-safe default seeding

collectSchemaDefaults assigned by name, so a legal field called `__proto__`
went to the legacy prototype setter instead of the result. A required one
pinned by `const` would then render read-only and be seeded with nothing,
leaving submit permanently disabled. Seeded through defineProperty now, the
way the other schema-record paths already build their maps.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-26 04:48:54 -04:00
commit 41be9e7aa8
2 changed files with 26 additions and 3 deletions

View file

@ -445,6 +445,18 @@ describe("root composition (#2123)", () => {
});
});
it("seeds a field named __proto__", () => {
// A plain assignment would invoke the legacy prototype setter, leaving a
// required pinned field displayed read-only and seeded with nothing.
const seeded = collectSchemaDefaults({
type: "object",
properties: Object.fromEntries([
["__proto__", { type: "string", const: "kept" }],
]),
});
expect(Object.hasOwn(seeded, "__proto__")).toBe(true);
});
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

View file

@ -145,6 +145,17 @@ export function collectSchemaDefaults(
const selected = selectBranchIndex(branches, knownValues) ?? 0;
const properties = (branches[selected]?.schema ?? base).properties ?? {};
const result: Record<string, unknown> = {};
// `properties` is a JSON record, so `__proto__` is a legal field name that a
// plain assignment would drop into the legacy prototype setter rather than
// keep — a required one pinned by `const` would then display read-only and
// be seeded with nothing, leaving submit permanently disabled (#2123).
const seed = (name: string, value: unknown) =>
Object.defineProperty(result, name, {
value,
writable: true,
enumerable: true,
configurable: true,
});
for (const [fieldName, rawSchema] of Object.entries(properties)) {
// Collapse a nullable union first, for the same reason `SchemaForm` does:
// a nested object's `properties` live on the union's surviving branch, so
@ -161,13 +172,13 @@ export function collectSchemaDefaults(
// `const` rejects, and seeding that would submit an invalid argument
// through a field rendered read-only, leaving the user no way to correct
// it.
result[fieldName] = fieldSchema.const;
seed(fieldName, fieldSchema.const);
} else if (fieldSchema.default !== undefined) {
result[fieldName] = fieldSchema.default;
seed(fieldName, fieldSchema.default);
} else if (fieldSchema.type === "object" && fieldSchema.properties) {
const nested = collectSchemaDefaults(fieldSchema);
if (Object.keys(nested).length > 0) {
result[fieldName] = nested;
seed(fieldName, nested);
}
}
}