perf(TextInput): skip redundant character counter updates#7600
perf(TextInput): skip redundant character counter updates#7600hectahertz wants to merge 3 commits intomainfrom
Conversation
🦋 Changeset detectedLatest commit: 73a0a87 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
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 |
|
There was a problem hiding this comment.
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.updateCharacterCountcalls in both thevalueeffect andonChangehandler 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
lastCountedLengthRefguard 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 withvalue+onChangeanduser.type) that asserts the counter/error state still updates correctly, and ideally thatCharacterCounter.updateCharacterCountis 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],
)
|
👋 Hi from github/github-ui! Your integration PR is ready: https://github.com/github/github-ui/pull/14862 |
Closes #
In a controlled
TextInputwithcharacterLimit, every keystroke was triggering 2 React render invocations instead of 1:handleInputChangecallsupdateCharacterCount(newLen)→setCharacterCount(msg)+setIsOverLimit(flag), batched with the parent'ssetValue→ one render + DOM commit.valueuseEffectfires next (becausevaluechanged), callsupdateCharacterCount(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. BothhandleInputChangeand thevalueeffect guard theirupdateCharacterCountcall behindcurrentLength !== lastCountedLengthRef.current. TheonChangehandler always fires first, so the effect always hits the guard and skips.setCharacterCountandsetIsOverLimitare also guarded insideonCountUpdateandonScreenReaderAnnouncewith value-equality checks.Performance measurements
Chrome DevTools MCP, 6x CPU throttle, React 18 dev build (
createRoot),characterLimit=20, controlled input (100 keystrokes).TextInputwithcharacterLimitChangelog
Changed
TextInputcharacter-counter update churn by skipping redundant count/state updates when the input length has not changed.aria-livemessage state updates when the announcement message is unchanged.Rollout strategy
Testing & Reviewing
runTestsforpackages/react/src/TextInput/TextInput.test.tsxand confirm all tests pass.Merge checklist