diff --git a/.changeset/fix-checkbox-onchange-target.md b/.changeset/fix-checkbox-onchange-target.md new file mode 100644 index 0000000000..94c13e0aa4 --- /dev/null +++ b/.changeset/fix-checkbox-onchange-target.md @@ -0,0 +1,12 @@ +--- +"@cloudflare/kumo": patch +--- + +fix(Checkbox): use Proxy instead of Object.assign to avoid crashing on read-only Event.target + +The deprecated `onChange` handler used `Object.assign(event, { target: ... })` which throws +`TypeError: Cannot set property target of # which has only a getter` because `Event.target` +is a read-only getter property. Replaced with `Object.create` to create a new object that shadows +the prototype getter with an own `target` property. + +Fixes #409 diff --git a/packages/kumo/src/components/checkbox/checkbox.test.tsx b/packages/kumo/src/components/checkbox/checkbox.test.tsx new file mode 100644 index 0000000000..aa2e4ee95e --- /dev/null +++ b/packages/kumo/src/components/checkbox/checkbox.test.tsx @@ -0,0 +1,32 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { Checkbox } from "./checkbox"; + +describe("Checkbox", () => { + it("preserves native event APIs on deprecated onChange callbacks", () => { + const events: Array> = []; + + render( + { + events.push(event); + }} + />, + ); + + expect(() => fireEvent.click(screen.getByRole("checkbox"))).not.toThrow(); + expect(events.length).toBeGreaterThan(0); + + const event = events[0]; + + expect(event).toBeDefined(); + expect(event.target.checked).toBe(true); + expect(event.currentTarget.checked).toBe(true); + expect(event instanceof Event).toBe(true); + + expect(() => event.preventDefault()).not.toThrow(); + expect(event.defaultPrevented).toBe(true); + expect(typeof event.timeStamp).toBe("number"); + }); +}); diff --git a/packages/kumo/src/components/checkbox/checkbox.tsx b/packages/kumo/src/components/checkbox/checkbox.tsx index 27838c08c7..bd685418d2 100644 --- a/packages/kumo/src/components/checkbox/checkbox.tsx +++ b/packages/kumo/src/components/checkbox/checkbox.tsx @@ -56,6 +56,41 @@ const CheckboxGroupContext = createContext<{ controlFirst: boolean }>({ controlFirst: true, }); +function brandSafeProxy( + source: T, + overrides: Record, +): T { + return new Proxy(source, { + get(target, property) { + if (Object.hasOwn(overrides, property)) { + return overrides[property]; + } + + const value = Reflect.get(target, property, target); + return typeof value === "function" ? value.bind(target) : value; + }, + }); +} + +function createLegacyCheckboxChangeEvent( + event: Event, + checked: boolean, +): React.ChangeEvent { + const target = brandSafeProxy( + (event.target ?? {}) as EventTarget & HTMLInputElement, + { checked }, + ); + const currentTarget = brandSafeProxy( + (event.currentTarget ?? {}) as EventTarget & HTMLInputElement, + { checked }, + ); + + return brandSafeProxy(event, { + target, + currentTarget, + }) as unknown as React.ChangeEvent; +} + /** * Single checkbox component props with accessibility guidance. * @@ -245,9 +280,10 @@ const CheckboxBase = forwardRef( if (onChange) { // Backwards compatibility: extend native event with target.checked // so existing code using `e.target.checked` continues to work - const event = Object.assign(eventDetails.event, { - target: { checked: newChecked }, - }); + const event = createLegacyCheckboxChangeEvent( + eventDetails.event, + newChecked, + ); onChange(event as never); } };