diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx index 47de56d2..002ead29 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx @@ -2286,6 +2286,58 @@ describe("SchemaForm enlarge keyboard access (#2138)", () => { expect(noteField().selectionStart).toBe(1); }); + // JSON Schema counts maxLength in Unicode code points; String.length counts + // UTF-16 code units, so an emoji reads as 2 and a field with room left is + // treated as full (#2139 review). + it("counts maxLength in code points, not UTF-16 units", () => { + renderWithMantine(); + + noteField().setSelectionRange(2, 2); + fireEvent.keyDown(noteField(), { key: "Enter" }); + + // One emoji plus one newline is two characters, so it fits. + expect(noteField().value).toBe("😀\n"); + }); + + // A field name that collides with something on Object.prototype must still + // take the documented end-of-value fallback when enlarged by pointer, rather + // than reading an inherited value off a bare record (#2139 review). + it("handles a field named after an Object.prototype member", async () => { + const user = userEvent.setup(); + + // Both halves are annotated rather than inlined. Under a `constructor` key + // the contextual type does not reach the nested literal, so `type: "string"` + // widens to `string` and fails to typecheck — the type-level echo of the + // very prototype collision this test is about. + const ctorField: InspectorFormSchema = { type: "string", title: "Ctor" }; + const prototypeSchema: InspectorFormSchema = { + type: "object", + properties: { constructor: ctorField }, + }; + + function PrototypeHarness() { + const [values, setValues] = useState>({ + constructor: "abc", + }); + return ( + + ); + } + + renderWithMantine(); + await user.click(screen.getByRole("button", { name: "Enlarge Ctor" })); + + const field = screen.getByRole("textbox", { + name: /Ctor/, + }) as HTMLTextAreaElement; + expect(field.tagName).toBe("TEXTAREA"); + expect(field.selectionStart).toBe("abc".length); + }); + // The control: the same seeded field with no maxLength does take the newline, // so the test above is showing the constraint at work rather than a field // that never accepts one. diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx index 52daebbb..934a5bc0 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx @@ -125,6 +125,20 @@ const TWO_ACTION_WIDTH = rem( // (#2138 review); a real Enter reports 13, so this cannot swallow one. const IME_KEY_CODE = 229; +/** + * Length in Unicode code points, which is how JSON Schema counts `maxLength` + * (its characters are RFC 8259 characters, i.e. code points). `String.length` + * counts UTF-16 code units instead, so it double-counts anything astral: a + * field holding a single emoji reads as 2 and a `maxLength: 2` field would be + * treated as full when it has room for another character. + * + * Note the `maxLength` handed to the DOM input still enforces the HTML + * attribute's own UTF-16 counting; that predates this and is not changed here. + */ +function countCodePoints(value: string): number { + return Array.from(value).length; +} + function serializeJson(value: unknown): string { return JSON.stringify(value, null, 2); } @@ -608,13 +622,18 @@ export function SchemaForm({ // Where the caret goes when a field's text area mounts, for the fields // enlarged by Enter (#2138). Absent for a field enlarged by the button, which // carries no position — see EnlargedStringField. + // A Map rather than a plain object: the keys are field names straight out of + // a server's schema, and on a bare record a field legitimately named + // `constructor` or `toString` reads back an inherited function instead of + // `undefined` — which would then be handed to setSelectionRange in place of + // the documented end-of-value fallback. const [enlargeCarets, setEnlargeCarets] = useState< - Readonly> - >({}); + ReadonlyMap + >(() => new Map()); useValueChange(resetKey, () => { setEnlargedFields(new Set()); - setEnlargeCarets({}); + setEnlargeCarets(new Map()); }); // Stable so a field's reporting effect subscribes once, not per render. The @@ -737,7 +756,7 @@ export function SchemaForm({ @@ -777,16 +796,15 @@ export function SchemaForm({ const next = `${current.slice(0, start)}\n${current.slice(end)}`; const fits = fieldSchema.maxLength === undefined || - next.length <= fieldSchema.maxLength; + countCodePoints(next) <= fieldSchema.maxLength; if (fits) handleFieldChange(fieldName, next); // The caret is recorded either way. The keyboard route always has a // real position, so dropping it when the newline does not fit would // send the caret to the end of a field the user was editing the middle // of — a second surprise on top of the newline they did not get. - setEnlargeCarets((previous) => ({ - ...previous, - [fieldName]: fits ? start + 1 : start, - })); + setEnlargeCarets((previous) => + new Map(previous).set(fieldName, fits ? start + 1 : start), + ); enlarge(); };