Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/fix-checkbox-onchange-target.md
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.
Comment on lines +7 to +10
Copy link
Copy Markdown
Contributor

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.create approach. Should be updated to reflect the Proxy implementation:

Suggested change
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.
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 a `Proxy` that intercepts property access to override
`target.checked` and `currentTarget.checked` while preserving native event APIs.


Fixes #409
32 changes: 32 additions & 0 deletions packages/kumo/src/components/checkbox/checkbox.test.tsx
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();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider also testing the unchecked → checked → unchecked round-trip to verify target.checked reflects false on the second click. This would catch any stale closure issues if the proxy construction were accidentally memoized in the future.

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);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 instanceof checks. The Proxy transparently delegates this because instanceof uses Symbol.hasInstance / prototype checks on the target.


expect(() => event.preventDefault()).not.toThrow();
expect(event.defaultPrevented).toBe(true);
expect(typeof event.timeStamp).toBe("number");
});
});
42 changes: 39 additions & 3 deletions packages/kumo/src/components/checkbox/checkbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: Object.hasOwn(overrides, property) works correctly for symbol keys too (since overrides is Record<PropertyKey, unknown>), which is good — this means the proxy won't accidentally intercept Symbol.toPrimitive or similar unless explicitly overridden.

return overrides[property];
}

const value = Reflect.get(target, property, target);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call on .bind(target) here — this is what preserves brand-checked methods like preventDefault() and stopPropagation() since they perform internal slot checks on this.

return typeof value === "function" ? value.bind(target) : value;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: value.bind(target) is called on every property access, creating a new bound function each time. For a deprecated compat path that fires once per click this is perfectly fine, but worth noting that the proxied event is not referentially stable for method lookups (i.e. event.preventDefault !== event.preventDefault). Not blocking — just documenting the trade-off.

},
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor note: this Proxy only defines a get trap. That means set operations (e.g. event.someProperty = value) will fall through to the original target object, which is the native Event. This is probably fine for this deprecated compatibility path — consumers should only be reading from the event — but worth being aware of.

If you wanted to be maximally defensive, you could add a set trap that writes to the overrides object instead. Not blocking, just flagging.

}

function createLegacyCheckboxChangeEvent(
event: Event,
checked: boolean,
): React.ChangeEvent<HTMLInputElement> {
const target = brandSafeProxy(
(event.target ?? {}) as EventTarget & HTMLInputElement,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The event.target ?? {} fallback means that if event.target is null (which can happen for programmatically constructed events), the proxy wraps a plain object {} instead. That's reasonable — consumers accessing target.checked will still get the correct value from the override, and other HTMLInputElement properties will just be undefined rather than throwing. Fine for a deprecated path.

{ 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.
*
Expand Down Expand Up @@ -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);
}
};
Expand Down