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
5 changes: 5 additions & 0 deletions .changeset/toggleswitch-onchange-from-handler.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': patch
---

ToggleSwitch: Fire `onChange` from the user interaction instead of an effect, so it no longer fires on mount or when a controlled `checked` value changes externally.
40 changes: 40 additions & 0 deletions packages/react/src/ToggleSwitch/ToggleSwitch.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,46 @@ describe('ToggleSwitch', () => {
expect(handleChange).toHaveBeenCalledWith(true)
})

it('does not call onChange on mount or when checked changes externally', () => {
const handleChange = vi.fn()
const {rerender} = render(
<>
<div id="switchLabel">{SWITCH_LABEL_TEXT}</div>
<ToggleSwitch checked={false} onChange={handleChange} aria-labelledby="switchLabel" />
</>,
)
expect(handleChange).not.toHaveBeenCalled()

rerender(
<>
<div id="switchLabel">{SWITCH_LABEL_TEXT}</div>
<ToggleSwitch checked={true} onChange={handleChange} aria-labelledby="switchLabel" />
</>,
)
expect(handleChange).not.toHaveBeenCalled()
})
Comment on lines +184 to +201

it('does not call onChange when a new inline onChange is passed on rerender', () => {
const handleChange = vi.fn()
const {rerender} = render(
<>
<div id="switchLabel">{SWITCH_LABEL_TEXT}</div>
<ToggleSwitch checked={false} onChange={() => handleChange()} aria-labelledby="switchLabel" />
</>,
)
expect(handleChange).not.toHaveBeenCalled()

// A new `onChange` identity each render previously re-ran the effect and
// fired the callback; it must not be called without a user interaction.
rerender(
<>
<div id="switchLabel">{SWITCH_LABEL_TEXT}</div>
<ToggleSwitch checked={false} onChange={() => handleChange()} aria-labelledby="switchLabel" />
</>,
)
expect(handleChange).not.toHaveBeenCalled()
})

it('can pass data attributes to the rendered component', async () => {
const TEST_ID = 'a test id'
const ControlledSwitchComponent = () => {
Expand Down
17 changes: 9 additions & 8 deletions packages/react/src/ToggleSwitch/ToggleSwitch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,20 +109,21 @@ const ToggleSwitch = React.forwardRef<HTMLButtonElement, ToggleSwitchProps>(func
e => {
if (disabled || loading) return

if (!isControlled) {
if (isControlled) {
// For controlled usage the click is the source of the change, so notify the
// consumer here rather than echoing the `checked` prop back from an effect
// (which fired on mount and whenever any of its dependencies — `checked`,
// `disabled`, or `onChange` — changed).
onChange?.(!isOn)
} else {
// `setIsOn` notifies `onChange` for uncontrolled usage.
setIsOn(!isOn)
}
onClick && onClick(e)
},
[disabled, isControlled, loading, onClick, setIsOn, isOn],
[disabled, isControlled, loading, onClick, onChange, setIsOn, isOn],
)

useEffect(() => {
if (onChange && isControlled && !disabled) {
onChange(Boolean(checked))
}
}, [onChange, checked, isControlled, disabled])

useEffect(() => {
if (!loading && isLoadingLabelVisible) {
// eslint-disable-next-line react-hooks/set-state-in-effect, react-you-might-not-need-an-effect/no-chain-state-updates
Expand Down
Loading