fix(Checkbox): preserve native event APIs in onChange compat layer#415
fix(Checkbox): preserve native event APIs in onChange compat layer#415singkia wants to merge 6 commits intocloudflare:mainfrom
Conversation
…rget
Object.assign(event, { target: ... }) throws TypeError because Event.target
is a read-only getter. Use Object.create to shadow the getter with an own
property instead.
Fixes cloudflare#409
Amp-Thread-ID: https://ampcode.com/threads/T-019d91d1-836e-718b-a3d0-4ed568514ccd
Co-authored-by: Amp <amp@ampcode.com>
|
/bigbonk |
There was a problem hiding this comment.
Overall this is a solid fix for a real bug. The Proxy-based approach is the right call here — it preserves brand-checked native event APIs (like preventDefault()) while overriding target.checked and currentTarget.checked for backwards compat. A few notes below.
Summary:
- The core
brandSafeProxy+createLegacyCheckboxChangeEventapproach is correct and well-structured. - The test covers the important behaviors.
- One real issue: the changeset description is stale — it mentions
Object.createbut the actual implementation usesProxy. - A couple of minor observations about the Proxy's trap coverage.
| 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. |
There was a problem hiding this comment.
Same issue — the body still describes the Object.create approach. Should be updated to reflect the Proxy implementation:
| 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. |
| const value = Reflect.get(target, property, target); | ||
| return typeof value === "function" ? value.bind(target) : value; | ||
| }, | ||
| }); |
There was a problem hiding this comment.
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.
| return overrides[property]; | ||
| } | ||
|
|
||
| const value = Reflect.get(target, property, target); |
There was a problem hiding this comment.
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.
| expect(event).toBeDefined(); | ||
| expect(event.target.checked).toBe(true); | ||
| expect(event.currentTarget.checked).toBe(true); | ||
| expect(event instanceof Event).toBe(true); |
There was a problem hiding this comment.
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.
commit: |
Co-authored-by: mattrothenberg <mattrothenberg@users.noreply.github.com>
|
Review submitted. Here's a summary of my findings: The fix is correct and well-implemented. The Proxy-based approach properly solves the Key feedback:
The test passes, and there are no new type errors introduced by this change. |
Co-authored-by: ask-bonk[bot] <249159057+ask-bonk[bot]@users.noreply.github.com>
|
/bigbonk will this crash in older browsers? we have to support a LOT of browsers |
|
@singkia If I were to tell you that we're planning on cutting a major release of Kumo that includes breaking changes (e.g., getting rid of |
There was a problem hiding this comment.
Good fix for a real crash. The Proxy approach is well-structured and the test provides solid regression coverage. A few observations beyond what bonk already flagged:
Summary
- The core
brandSafeProxy+createLegacyCheckboxChangeEventapproach correctly solves theTypeErroronEvent.targetwhile preserving brand-checked native APIs. - The test covers the critical behaviors (
target.checked,currentTarget.checked,preventDefault(),instanceof Event). - The stale changeset body (mentions
Object.createinstead ofProxy) should be fixed per bonk's suggestions. - A couple of additional observations below.
| } | ||
|
|
||
| const value = Reflect.get(target, property, target); | ||
| return typeof value === "function" ? value.bind(target) : value; |
There was a problem hiding this comment.
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.
| ): T { | ||
| return new Proxy(source, { | ||
| get(target, property) { | ||
| if (Object.hasOwn(overrides, property)) { |
There was a problem hiding this comment.
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.
| checked: boolean, | ||
| ): React.ChangeEvent<HTMLInputElement> { | ||
| const target = brandSafeProxy( | ||
| (event.target ?? {}) as EventTarget & HTMLInputElement, |
There was a problem hiding this comment.
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.
| />, | ||
| ); | ||
|
|
||
| expect(() => fireEvent.click(screen.getByRole("checkbox"))).not.toThrow(); |
There was a problem hiding this comment.
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.
|
Review submitted successfully. Here's a summary of my findings: Review Summary for PR #415Overall: This is a solid fix for a real bug. The Key points from my review:
The test passes, no new type errors are introduced (the only |
What
Fix the deprecated
onChangecompatibility path onCheckboxso it no longer crashes on read-onlyEvent.targetand still preserves native event APIs.Why
Object.assign(eventDetails.event, { target: { checked: newChecked } })tries to write toEvent.target, which is a read-only getter on native DOM events and crashes in modern browsers.The first fix using
Object.create(eventDetails.event, { target: ... })avoided that write, but it also stopped passing a real native event object through the deprecatedonChangepath. Brand-checked APIs likepreventDefault(),defaultPrevented, andtimeStampcould then break.This version proxies the original event and only overrides
target.checkedandcurrentTarget.checked, while binding the rest of the event API back to the native event object.Test Results
Added a focused unit test for the deprecated
onChangecompatibility path that verifies:target.checkedandcurrentTarget.checkedreflect the new valuepreventDefault(),defaultPrevented, andtimeStampstill workCommands run:
pnpm --filter @cloudflare/kumo exec vitest run src/components/checkbox/checkbox.test.tsxpnpm --filter @cloudflare/kumo exec tsc --noEmitpnpm --filter @cloudflare/kumo test(still hits the pre-existingtests/imports/export-path-validation.test.tsfailures fortable-of-contentsdist artifacts)Fixes #409
AI disclosure: Claude Opus 4.6 (via Amp) authored the original patch; GPT 5.4 (via Codex) updated the fix after review to preserve native event semantics and added regression coverage.