Skip to content

Checkbox: replace useLayoutEffect with ref callback for indeterminate#7765

Open
alexus37 wants to merge 3 commits intomainfrom
checkbox-ref-callback-indeterminate
Open

Checkbox: replace useLayoutEffect with ref callback for indeterminate#7765
alexus37 wants to merge 3 commits intomainfrom
checkbox-ref-callback-indeterminate

Conversation

@alexus37
Copy link
Copy Markdown

Summary

Replaces useLayoutEffect and useEffect in the Checkbox component with a ref callback pattern, eliminating layout effects from every Checkbox render.

Changes

  • useLayoutEffect → ref callback: Uses useCallback + useMergedRefs to set .indeterminate on the DOM node synchronously during commit (same timing guarantees, no registered effect)
  • useEffect → inline JSX: Replaces the imperative aria-checked effect with a declarative aria-checked={indeterminate ? 'mixed' : undefined} attribute. Non-indeterminate checkboxes rely on native checked state for accessibility.
  • Removed unused imports: useEffect, useLayoutEffect, useProvidedRefOrCreate

Motivation

Layout effects run synchronously after DOM mutations and block painting. While individually cheap, they add up in pages rendering many checkboxes (e.g., list views with bulk selection). This change removes one layout effect and one regular effect per Checkbox instance.

Testing

  • All 11 existing Checkbox tests pass
  • Updated aria-checked test to reflect that non-indeterminate checkboxes use native checked state

Closes #7764

Replace useLayoutEffect + useEffect with a ref callback pattern using
useMergedRefs to set the indeterminate DOM property. This eliminates
layout effects from every Checkbox render while maintaining the same
synchronous timing guarantees.

- Use useCallback ref to set .indeterminate on the DOM node
- Merge forwarded ref with indeterminate callback via useMergedRefs
- Replace imperative aria-checked useEffect with inline JSX attribute
- Update tests to reflect that non-indeterminate checkboxes rely on
  native checked state for accessibility

Closes #7764

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Apr 17, 2026

🦋 Changeset detected

Latest commit: b13ac8a

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

This PR includes changesets to release 1 package
Name Type
@primer/react 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

@github-actions
Copy link
Copy Markdown
Contributor

⚠️ Action required

👋 Hi, this pull request contains changes to the source code that github/github-ui depends on. If you are GitHub staff, test these changes with github/github-ui using the integration workflow. Check the integration testing docs for step-by-step instructions. Or, apply the integration-tests: skipped manually label to skip these checks.

To publish a canary release for integration testing, apply the Canary Release label to this PR.

@github-actions github-actions bot added the integration-tests: recommended This change needs to be tested for breaking changes. See https://arc.net/l/quote/tdmpakpm label Apr 17, 2026
alexus37 and others added 2 commits April 17, 2026 14:20
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR removes per-render React effects from Checkbox by using a ref callback to set the DOM indeterminate property during commit and by making aria-checked declarative for the indeterminate case.

Changes:

  • Replaced useLayoutEffect indeterminate synchronization with a useCallback ref callback merged via useMergedRefs.
  • Replaced the imperative aria-checked useEffect with a declarative aria-checked={indeterminate ? 'mixed' : undefined}.
  • Updated the related unit test and added a changeset entry.
Show a summary per file
File Description
packages/react/src/Checkbox/Checkbox.tsx Removes layout/effects by using a merged ref callback for indeterminate and declarative aria-checked for indeterminate.
packages/react/src/Checkbox/Checkbox.test.tsx Updates assertions to align with relying on native checked semantics when not indeterminate.
.changeset/checkbox-ref-callback.md Documents the patch-level change in a changeset.

Copilot's findings

  • Files reviewed: 3/3 changed files
  • Comments generated: 3

Comment on lines +74 to 78
['aria-checked']: indeterminate ? ('mixed' as const) : undefined,
onChange: handleOnChange,
value,
name: value,
...rest,
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

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

...rest is spread after the computed props, so a consumer-provided aria-checked (and, in React 19 where ref is a normal prop, potentially ref) can override the component-controlled values. That’s a behavior change vs the previous effect-based implementation and can break the required aria-checked="mixed" semantics for indeterminate checkboxes (or bypass setIndeterminate if ref is overridden). Consider spreading rest earlier (or explicitly omitting/overriding aria-checked/ref) so the indeterminate accessibility and ref callback behavior can’t be overridden unintentionally.

Copilot uses AI. Check for mistakes.
},
// `checked` is intentionally included: browsers clear the indeterminate state
// when checked changes, so we need the callback to re-run to restore it.
// eslint-disable-next-line react-hooks/exhaustive-deps
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

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

The eslint-disable-next-line react-hooks/exhaustive-deps looks unnecessary here because the dependency list is already explicit and complete. Keeping this suppression can hide real missing-dependency issues in future edits; consider removing the disable and leaving the explanatory comment about checked triggering a rerun.

Suggested change
// eslint-disable-next-line react-hooks/exhaustive-deps

Copilot uses AI. Check for mistakes.
Comment on lines 100 to 104
it('renders an aria-checked attribute correctly', () => {
const handleChange = vi.fn()
const {getByRole, rerender} = render(<Checkbox checked={false} onChange={handleChange} />)

const checkbox = getByRole('checkbox') as HTMLInputElement
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

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

This test name no longer matches what’s being asserted (it now checks checkbox.checked rather than aria-checked for the non-indeterminate cases). Consider renaming the test and/or explicitly asserting that aria-checked is not present when indeterminate is false, so the declarative behavior is actually covered.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

integration-tests: recommended This change needs to be tested for breaking changes. See https://arc.net/l/quote/tdmpakpm

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Checkbox: replace useLayoutEffect with ref callback for indeterminate prop

2 participants