Skip to content

fix: call onChange for clicks on detached checkboxes and radios - #118

Draft
bilalvur wants to merge 4 commits into
TanStack:mainfrom
bilalvur:fix/checkbox-change-on-click
Draft

bilalvur wants to merge 4 commits into
TanStack:mainfrom
bilalvur:fix/checkbox-change-on-click

Conversation

@bilalvur

@bilalvur bilalvur commented Oct 30, 2026 •

Copy link
Copy Markdown

Refs #115. Stacked on #116; this covers its last "Not covered" item. Until #116 merges, this diff includes its commits; review only the last one (c425e2a).

Browsers fire no change when a checkbox or radio outside the document is clicked, e.g. input.click() in a container never appended. Redact maps their onChange to change, so neither the handler nor #116's restore ran. React reads them from click (shouldUseClickEvent), which reaches detached elements, and fires onChange when its value tracker sees checked change.

What changes

  • A checkbox's or radio's onChange also listens for click, but only acts while the element is detached. Attached controls keep change, so nothing changes for them.
  • A detached radio fires only if it was unchecked, judged by its checked prop, else the state the last restore saw, else its default. The restore then undoes a rejected click.
  • As in React, a detached click that onClick prevents still calls onChange.

Switching attached controls to click too would match React there as well (onChange despite a prevented click, none for a dispatched change), but it would change event order and restore timing for every app to fix a detached-only case.

Size

+65 to +97 B gzip against #116; three entries go over budget. DETACHED_CHANGE_SIZE_ALLOWANCE holds the measured increments and needs your approval. If that's too much for a detached-only gap, I'm happy to close this.

Not covered

All detached only:

  • Listeners run in prop order, so onChange written before onClick runs first; React always runs onClick first.
  • An uncontrolled radio takes its previous state from the last restore, so it misses onChange after a handler-less sibling was checked, and repeats it for two clicks with no await between.
  • The restore stays asynchronous, as in fix: restore controlled form controls after onChange #116; React's is synchronous.

Testing

Removing the fix fails 10 of the 17 new jsdom tests; the other 7 check cases where nothing may fire or change. The browser test confirms browsers fire no change for detached controls, and its detached cases fail without the fix in Chromium, Firefox and WebKit. pnpm test:pr passes on Node 24.22.0, and the actions browser suite passes in all three browsers.

I used Claude Code to write the fix and tests, and had it run the suites and a separate review pass.

bilalvur and others added 4 commits October 30, 2026 17:37
React writes a controlled control's value and checked state back after
its change handlers run. Redact only synced them on re-render, so a
handler that rejected an edit by not calling setState left the typed
text, checkbox toggle, radio selection or select choice in the DOM.

The restore runs after the element's last Redact listener for the
native input or change event, so onChange, onInput and their capture
forms all read the edit, also with real user input, which runs
microtasks between listeners. It is queued behind the handler's own
updates (microtask, or act's queue), so it reads the props they render
and keeps the caret of an accepted edit. Hydration records the props
too, and a radio restores the rest of its group.

Fixes TanStack#115.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
These controls fire input before change. With onInput and onChange both
set, the restore ran after the input listener, and since real clicks
run microtasks between events, onChange read the restored state
instead of the click. A change listener now owns the restore for them;
an onInput alone still restores after input.

Refs TanStack#115.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The restore ran in a microtask after the control's last Redact listener,
and real input runs microtasks between listeners. So an ancestor's
onInput or a later native listener read the restored value, and an
ancestor that accepted the edit lost it. A capture listener that
stopped propagation also skipped the restore: the target's bubble
listener, which owned it, never ran.

The control's listeners now mark the native event, and listeners on the
root and portal containers restore once it reaches them, as React's
root listener does. A stopped or non-bubbling event restores at once,
since it never reaches a container. Listening on the container rather
than the document keeps the restore when a native listener on the
container stops propagation, and covers shadow roots.

A checkbox, radio or select with a change listener still restores after
change; with only onInput it restores after input.

Refs TanStack#115.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Browsers fire no `change` event when a checkbox or radio that is not
connected to a document is clicked, for example `input.click()` on an
element in a detached container. Redact maps a checkbox's or radio's
onChange to the native `change` event, so neither the handler nor the
controlled restore from TanStack#116 ran there. React reads checkbox and radio
changes from `click`, which also reaches detached elements.

The onChange listener now also listens for `click`, but acts on it
only while the element is detached, and for a radio only when it was
not checked before: its controlled `checked` prop, the checked state
the last restore saw, or its default. Attached controls keep their
`change` event, so their timing, the effect of preventDefault on the
click and native `change` dispatches are unchanged. A full switch to
`click` would change all of these for every app to cover a case that
only arises outside the document.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Oct 30, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: c425e2a

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@tanstack/redact Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coderabbitai

coderabbitai Bot commented Oct 30, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant