Skip to content

perf(TextInput): skip redundant character counter updates#7600

Open
hectahertz wants to merge 3 commits intomainfrom
hectahertz/perf-textinput-character-counter-updates
Open

perf(TextInput): skip redundant character counter updates#7600
hectahertz wants to merge 3 commits intomainfrom
hectahertz/perf-textinput-character-counter-updates

Conversation

@hectahertz
Copy link
Contributor

@hectahertz hectahertz commented Feb 26, 2026

Closes #

In a controlled TextInput with characterLimit, every keystroke was triggering 2 React render invocations instead of 1:

  1. Batch render (committed): handleInputChange calls updateCharacterCount(newLen)setCharacterCount(msg) + setIsOverLimit(flag), batched with the parent's setValue → one render + DOM commit.
  2. Wasted bailout render (not committed): the value useEffect fires next (because value changed), calls updateCharacterCount(newLen) a second time → setCharacterCount(sameMsg) + setIsOverLimit(sameFlag). React 18 runs the full component function and reconciler to determine nothing changed, then discards the result without committing.

The fix: track the last-counted length in a lastCountedLengthRef. Both handleInputChange and the value effect guard their updateCharacterCount call behind currentLength !== lastCountedLengthRef.current. The onChange handler always fires first, so the effect always hits the guard and skips. setCharacterCount and setIsOverLimit are also guarded inside onCountUpdate and onScreenReaderAnnounce with value-equality checks.

Performance measurements

Chrome DevTools MCP, 6x CPU throttle, React 18 dev build (createRoot), characterLimit=20, controlled input (100 keystrokes).

Scenario React renders Breakdown flushSync total
Before (main) 200 2 per keystroke (1 commit + 1 bailout) 21 ms
After (this PR) 100 1 per keystroke (1 commit + 0 bailout) 12 ms
  • Eliminates 1 wasted bailout render per controlled keystroke on any TextInput with characterLimit
  • Each eliminated bailout render ran the full component function + React reconciler without producing a DOM commit; at 6x throttle that costs ~0.09 ms/keystroke
  • The benchmark measures the TextInput subtree in isolation — on production pages the savings scale with parent tree depth and subscriber count

Changelog

Changed

  • Reduce TextInput character-counter update churn by skipping redundant count/state updates when the input length has not changed.
  • Avoid redundant aria-live message state updates when the announcement message is unchanged.

Rollout strategy

  • Patch release
  • Minor release
  • Major release; if selected, include a written rollout or migration plan
  • None; if selected, include a brief description as to why

Testing & Reviewing

Merge checklist

@changeset-bot
Copy link

changeset-bot bot commented Feb 26, 2026

🦋 Changeset detected

Latest commit: 73a0a87

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 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 Feb 26, 2026
@github-actions
Copy link
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. Or, apply the integration-tests: skipped manually label to skip these checks.

Copy link
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

Optimizes @primer/react’s TextInput character counter behavior to avoid redundant state updates/renders in controlled inputs with characterLimit, reducing wasted React work per keystroke.

Changes:

  • Added refs to track last-counted input length and last-emitted counter/over-limit/aria-live messages to skip redundant updates.
  • Guarded CharacterCounter.updateCharacterCount calls in both the value effect and onChange handler when length is unchanged.
  • Added a changeset for a patch release.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.

File Description
packages/react/src/TextInput/TextInput.tsx Adds ref-based guards to avoid redundant character counter updates and state churn.
.changeset/perf-textinput-character-counter-updates.md Declares a patch changeset entry for the performance fix.
Comments suppressed due to low confidence (1)

packages/react/src/TextInput/TextInput.tsx:223

  • The new lastCountedLengthRef guard is primarily intended to avoid redundant counter updates in controlled TextInput usage, but the existing test suite only exercises the character counter in uncontrolled mode. Please add a controlled-input test (e.g. wrapper component with value+onChange and user.type) that asserts the counter/error state still updates correctly, and ideally that CharacterCounter.updateCharacterCount is not invoked twice per keystroke (can be done by spying/mocking the class).
    // Update character count when value changes or on mount
    useEffect(() => {
      if (characterLimit && characterCounterRef.current) {
        const currentValue =
          value !== undefined ? String(value) : defaultValue !== undefined ? String(defaultValue) : ''
        const currentLength = currentValue.length

        if (currentLength !== lastCountedLengthRef.current) {
          lastCountedLengthRef.current = currentLength
          characterCounterRef.current.updateCharacterCount(currentLength, characterLimit)
        }
      }
    }, [value, defaultValue, characterLimit])

    // Handle input change with character counter
    const handleInputChange = useCallback(
      (e: React.ChangeEvent<HTMLInputElement>) => {
        if (characterLimit && characterCounterRef.current) {
          const currentLength = e.target.value.length

          if (currentLength !== lastCountedLengthRef.current) {
            lastCountedLengthRef.current = currentLength
            characterCounterRef.current.updateCharacterCount(currentLength, characterLimit)
          }
        }
        onChange?.(e)
      },
      [onChange, characterLimit],
    )

@hectahertz hectahertz enabled auto-merge February 27, 2026 12:12
@primer-integration
Copy link

👋 Hi from github/github-ui! Your integration PR is ready: https://github.com/github/github-ui/pull/14862

@primer-integration
Copy link

Integration test results from github/github-ui:

Passed  CI   Passed
Passed  VRT   Passed
Passed  Projects   Passed

All checks passed!

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.

3 participants