-
Notifications
You must be signed in to change notification settings - Fork 90
fix(Checkbox): preserve native event APIs in onChange compat layer #415
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
cadb6b8
30d7083
fd9ae7b
d3e4ac9
a2ecfe2
b5e51ac
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 #<Event> 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 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<React.ChangeEvent<HTMLInputElement>> = []; | ||
|
|
||
| render( | ||
| <Checkbox | ||
| label="Accept terms" | ||
| onChange={(event) => { | ||
| events.push(event); | ||
| }} | ||
| />, | ||
| ); | ||
|
|
||
| expect(() => fireEvent.click(screen.getByRole("checkbox"))).not.toThrow(); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Consider also testing the unchecked → checked → unchecked round-trip to verify Non-blocking, but would strengthen the test. |
||
| 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); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nice — this assertion is the key one that validates the Proxy preserves the prototype chain for |
||
|
|
||
| expect(() => event.preventDefault()).not.toThrow(); | ||
| expect(event.defaultPrevented).toBe(true); | ||
| expect(typeof event.timeStamp).toBe("number"); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -56,6 +56,41 @@ const CheckboxGroupContext = createContext<{ controlFirst: boolean }>({ | |
| controlFirst: true, | ||
| }); | ||
|
|
||
| function brandSafeProxy<T extends object>( | ||
| source: T, | ||
| overrides: Record<PropertyKey, unknown>, | ||
| ): T { | ||
| return new Proxy(source, { | ||
| get(target, property) { | ||
| if (Object.hasOwn(overrides, property)) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Minor: |
||
| return overrides[property]; | ||
| } | ||
|
|
||
| const value = Reflect.get(target, property, target); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good call on |
||
| return typeof value === "function" ? value.bind(target) : value; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: |
||
| }, | ||
| }); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Minor note: this Proxy only defines a If you wanted to be maximally defensive, you could add a |
||
| } | ||
|
|
||
| function createLegacyCheckboxChangeEvent( | ||
| event: Event, | ||
| checked: boolean, | ||
| ): React.ChangeEvent<HTMLInputElement> { | ||
| const target = brandSafeProxy( | ||
| (event.target ?? {}) as EventTarget & HTMLInputElement, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The |
||
| { checked }, | ||
| ); | ||
| const currentTarget = brandSafeProxy( | ||
| (event.currentTarget ?? {}) as EventTarget & HTMLInputElement, | ||
| { checked }, | ||
| ); | ||
|
|
||
| return brandSafeProxy(event, { | ||
| target, | ||
| currentTarget, | ||
| }) as unknown as React.ChangeEvent<HTMLInputElement>; | ||
| } | ||
|
|
||
| /** | ||
| * Single checkbox component props with accessibility guidance. | ||
| * | ||
|
|
@@ -245,9 +280,10 @@ const CheckboxBase = forwardRef<HTMLButtonElement, CheckboxProps>( | |
| 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); | ||
| } | ||
| }; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same issue — the body still describes the
Object.createapproach. Should be updated to reflect the Proxy implementation: