Skip to content
Draft
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/checkbox-indeterminate-ref-callback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': patch
---

Checkbox: Improve rendering performance by setting the indeterminate state with a ref callback instead of a layout effect
59 changes: 35 additions & 24 deletions packages/react/src/Checkbox/Checkbox.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import {clsx} from 'clsx'
import {useProvidedRefOrCreate} from '../hooks'
import React, {useContext, useEffect, type ChangeEventHandler, type InputHTMLAttributes, type ReactElement} from 'react'
import useLayoutEffect from '../utils/useIsomorphicLayoutEffect'
import {useMergedRefs} from '../hooks'
import React, {
useCallback,
useContext,
useRef,
type ChangeEventHandler,
type InputHTMLAttributes,
type ReactElement,
} from 'react'
import type {FormValidationStatus} from '../utils/types/FormValidationStatus'
import {CheckboxGroupContext} from '../CheckboxGroup/CheckboxGroupContext'
import classes from './Checkbox.module.css'
Expand Down Expand Up @@ -58,7 +64,7 @@ const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
ref,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
): ReactElement<any> => {
const checkboxRef = useProvidedRefOrCreate(ref as React.RefObject<HTMLInputElement>)
const checkboxRef = useRef<HTMLInputElement>(null)
const checkboxGroupContext = useContext(CheckboxGroupContext)
const handleOnChange: ChangeEventHandler<HTMLInputElement> = e => {
checkboxGroupContext.onChange && checkboxGroupContext.onChange(e)
Expand All @@ -69,10 +75,34 @@ const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
checkboxRef.current.setAttribute('aria-checked', 'mixed')
}
}

// Ref callback that runs synchronously during commit (same timing as
// `useLayoutEffect`) to imperatively set the DOM `.indeterminate` property,
// which has no HTML attribute equivalent, along with the matching
// `aria-checked` value. This avoids a layout effect per Checkbox instance.
const setIndeterminate = useCallback(
(node: HTMLInputElement | null) => {
if (node) {
node.indeterminate = indeterminate || false
if (indeterminate) {
node.setAttribute('aria-checked', 'mixed')
} else {
node.setAttribute('aria-checked', node.checked ? 'true' : 'false')
}
}
},
// `checked` is intentionally included: the callback reads `node.checked` (to also
// cover uncontrolled inputs), so it must re-run to refresh `aria-checked` when the
// controlled `checked` prop changes. eslint can't see this indirect dependency.
// eslint-disable-next-line react-hooks/exhaustive-deps
[indeterminate, checked],
)
const mergedRef = useMergedRefs(ref, useMergedRefs(checkboxRef, setIndeterminate))

const inputProps = {
type: 'checkbox',
disabled,
ref: checkboxRef,
ref: mergedRef,
checked: indeterminate ? false : checked,
defaultChecked,
required,
Expand All @@ -84,26 +114,7 @@ const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
...rest,
}

useLayoutEffect(() => {
if (checkboxRef.current) {
checkboxRef.current.indeterminate = indeterminate || false
}
}, [indeterminate, checked, checkboxRef])

useEffect(() => {
const {current: checkbox} = checkboxRef
if (!checkbox) {
return
}

if (indeterminate) {
checkbox.setAttribute('aria-checked', 'mixed')
} else {
checkbox.setAttribute('aria-checked', checkbox.checked ? 'true' : 'false')
}
})
return (
// @ts-expect-error inputProp needs a non nullable ref
<input
{...inputProps}
data-component={dataComponent ?? 'Checkbox'}
Expand Down
Loading