From 87e71c2d1eba3887f6593b42d141a9894738551e Mon Sep 17 00:00:00 2001 From: hectahertz Date: Thu, 26 Feb 2026 13:10:51 +0100 Subject: [PATCH 1/5] perf(Dialog): replace :has(.Footer) with data-has-footer attribute --- packages/react/src/Dialog/Dialog.module.css | 2 +- packages/react/src/Dialog/Dialog.tsx | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/react/src/Dialog/Dialog.module.css b/packages/react/src/Dialog/Dialog.module.css index ac5a664c148..c87b9321e56 100644 --- a/packages/react/src/Dialog/Dialog.module.css +++ b/packages/react/src/Dialog/Dialog.module.css @@ -324,7 +324,7 @@ Add a border between the body and footer if: - the dialog has a body that can scroll - the browser supports the `animation-timeline` property and its `scroll()` function */ -.Dialog:has(.Footer) { +.Dialog[data-has-footer] { --can-scroll: 0; .DialogOverflowWrapper { diff --git a/packages/react/src/Dialog/Dialog.tsx b/packages/react/src/Dialog/Dialog.tsx index 2a070a88d66..dc2ce623e94 100644 --- a/packages/react/src/Dialog/Dialog.tsx +++ b/packages/react/src/Dialog/Dialog.tsx @@ -370,6 +370,7 @@ const _Dialog = React.forwardRef From 26bd85832a98e876c7ceeb90fc8de2ee761ddce2 Mon Sep 17 00:00:00 2001 From: hectahertz Date: Thu, 26 Feb 2026 13:11:02 +0100 Subject: [PATCH 2/5] chore: changeset --- .changeset/perf-dialog-css-has-selector.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/perf-dialog-css-has-selector.md diff --git a/.changeset/perf-dialog-css-has-selector.md b/.changeset/perf-dialog-css-has-selector.md new file mode 100644 index 00000000000..b4fb349eebc --- /dev/null +++ b/.changeset/perf-dialog-css-has-selector.md @@ -0,0 +1,5 @@ +--- +'@primer/react': patch +--- + +perf(Dialog): replace `:has(.Footer)` with `[data-has-footer]` attribute selector for footer border detection From b6ef2a1f79f6d5e073bd5b61b83843f896bf4075 Mon Sep 17 00:00:00 2001 From: hectahertz Date: Thu, 26 Feb 2026 13:41:54 +0100 Subject: [PATCH 3/5] perf(TextInput): skip redundant character counter updates --- packages/react/src/TextInput/TextInput.tsx | 40 +++++++++++++++++++--- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/packages/react/src/TextInput/TextInput.tsx b/packages/react/src/TextInput/TextInput.tsx index 6f30c31428e..88a4c716226 100644 --- a/packages/react/src/TextInput/TextInput.tsx +++ b/packages/react/src/TextInput/TextInput.tsx @@ -108,6 +108,10 @@ const TextInput = React.forwardRef( const [isOverLimit, setIsOverLimit] = useState(false) const [screenReaderMessage, setScreenReaderMessage] = useState('') const characterCounterRef = useRef(null) + const lastCountedLengthRef = useRef(null) + const lastCharacterCountRef = useRef('') + const lastIsOverLimitRef = useRef(false) + const lastScreenReaderMessageRef = useRef('') // this class is necessary to style FilterSearch, plz no touchy! const wrapperClasses = clsx(className, 'TextInput-wrapper') @@ -157,17 +161,33 @@ const TextInput = React.forwardRef( if (characterLimit) { characterCounterRef.current = new CharacterCounter({ onCountUpdate: (count, overLimit, message) => { - setCharacterCount(message) - setIsOverLimit(overLimit) + if (message !== lastCharacterCountRef.current) { + lastCharacterCountRef.current = message + setCharacterCount(message) + } + + if (overLimit !== lastIsOverLimitRef.current) { + lastIsOverLimitRef.current = overLimit + setIsOverLimit(overLimit) + } }, onScreenReaderAnnounce: message => { - setScreenReaderMessage(message) + if (message !== lastScreenReaderMessageRef.current) { + lastScreenReaderMessageRef.current = message + setScreenReaderMessage(message) + } }, }) + lastCountedLengthRef.current = null + return () => { characterCounterRef.current?.cleanup() characterCounterRef.current = null + lastCountedLengthRef.current = null + lastCharacterCountRef.current = '' + lastIsOverLimitRef.current = false + lastScreenReaderMessageRef.current = '' } } }, [characterLimit]) @@ -177,7 +197,12 @@ const TextInput = React.forwardRef( if (characterLimit && characterCounterRef.current) { const currentValue = value !== undefined ? String(value) : defaultValue !== undefined ? String(defaultValue) : '' - characterCounterRef.current.updateCharacterCount(currentValue.length, characterLimit) + const currentLength = currentValue.length + + if (currentLength !== lastCountedLengthRef.current) { + lastCountedLengthRef.current = currentLength + characterCounterRef.current.updateCharacterCount(currentLength, characterLimit) + } } }, [value, defaultValue, characterLimit]) @@ -185,7 +210,12 @@ const TextInput = React.forwardRef( const handleInputChange = useCallback( (e: React.ChangeEvent) => { if (characterLimit && characterCounterRef.current) { - characterCounterRef.current.updateCharacterCount(e.target.value.length, characterLimit) + const currentLength = e.target.value.length + + if (currentLength !== lastCountedLengthRef.current) { + lastCountedLengthRef.current = currentLength + characterCounterRef.current.updateCharacterCount(currentLength, characterLimit) + } } onChange?.(e) }, From c45fef6e44d1dd6600a78fbe570d549509219bd1 Mon Sep 17 00:00:00 2001 From: hectahertz Date: Wed, 4 Mar 2026 09:08:44 +0100 Subject: [PATCH 4/5] fix(Dialog): remove unrelated TextInput changes --- packages/react/src/TextInput/TextInput.tsx | 40 +++------------------- 1 file changed, 5 insertions(+), 35 deletions(-) diff --git a/packages/react/src/TextInput/TextInput.tsx b/packages/react/src/TextInput/TextInput.tsx index 88a4c716226..6f30c31428e 100644 --- a/packages/react/src/TextInput/TextInput.tsx +++ b/packages/react/src/TextInput/TextInput.tsx @@ -108,10 +108,6 @@ const TextInput = React.forwardRef( const [isOverLimit, setIsOverLimit] = useState(false) const [screenReaderMessage, setScreenReaderMessage] = useState('') const characterCounterRef = useRef(null) - const lastCountedLengthRef = useRef(null) - const lastCharacterCountRef = useRef('') - const lastIsOverLimitRef = useRef(false) - const lastScreenReaderMessageRef = useRef('') // this class is necessary to style FilterSearch, plz no touchy! const wrapperClasses = clsx(className, 'TextInput-wrapper') @@ -161,33 +157,17 @@ const TextInput = React.forwardRef( if (characterLimit) { characterCounterRef.current = new CharacterCounter({ onCountUpdate: (count, overLimit, message) => { - if (message !== lastCharacterCountRef.current) { - lastCharacterCountRef.current = message - setCharacterCount(message) - } - - if (overLimit !== lastIsOverLimitRef.current) { - lastIsOverLimitRef.current = overLimit - setIsOverLimit(overLimit) - } + setCharacterCount(message) + setIsOverLimit(overLimit) }, onScreenReaderAnnounce: message => { - if (message !== lastScreenReaderMessageRef.current) { - lastScreenReaderMessageRef.current = message - setScreenReaderMessage(message) - } + setScreenReaderMessage(message) }, }) - lastCountedLengthRef.current = null - return () => { characterCounterRef.current?.cleanup() characterCounterRef.current = null - lastCountedLengthRef.current = null - lastCharacterCountRef.current = '' - lastIsOverLimitRef.current = false - lastScreenReaderMessageRef.current = '' } } }, [characterLimit]) @@ -197,12 +177,7 @@ const TextInput = React.forwardRef( 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) - } + characterCounterRef.current.updateCharacterCount(currentValue.length, characterLimit) } }, [value, defaultValue, characterLimit]) @@ -210,12 +185,7 @@ const TextInput = React.forwardRef( const handleInputChange = useCallback( (e: React.ChangeEvent) => { if (characterLimit && characterCounterRef.current) { - const currentLength = e.target.value.length - - if (currentLength !== lastCountedLengthRef.current) { - lastCountedLengthRef.current = currentLength - characterCounterRef.current.updateCharacterCount(currentLength, characterLimit) - } + characterCounterRef.current.updateCharacterCount(e.target.value.length, characterLimit) } onChange?.(e) }, From fb8e57a454aeb0afcd13cad0457582fe153ad315 Mon Sep 17 00:00:00 2001 From: hectahertz Date: Wed, 4 Mar 2026 09:30:54 +0100 Subject: [PATCH 5/5] fix(Dialog): use nullish check for data-has-footer, add tests --- packages/react/src/Dialog/Dialog.test.tsx | 18 ++++++++++++++++++ packages/react/src/Dialog/Dialog.tsx | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/react/src/Dialog/Dialog.test.tsx b/packages/react/src/Dialog/Dialog.test.tsx index 65297c762c7..c5b25b2fb09 100644 --- a/packages/react/src/Dialog/Dialog.test.tsx +++ b/packages/react/src/Dialog/Dialog.test.tsx @@ -40,6 +40,24 @@ describe('Dialog', () => { await waitFor(() => expect(getByRole('button', {name: 'Footer button'})).toHaveFocus()) }) + it('sets data-has-footer when footerButtons are provided', () => { + const {getByRole} = render( + {}} footerButtons={[{buttonType: 'primary', content: 'OK'}]}> + Content + , + ) + expect(getByRole('dialog')).toHaveAttribute('data-has-footer', '') + }) + + it('does not set data-has-footer when no footer is rendered', () => { + const {getByRole} = render( + {}} renderFooter={() => null}> + Content + , + ) + expect(getByRole('dialog')).not.toHaveAttribute('data-has-footer') + }) + it('calls `onClose` when clicking the close button', async () => { const user = userEvent.setup() const onClose = vi.fn() diff --git a/packages/react/src/Dialog/Dialog.tsx b/packages/react/src/Dialog/Dialog.tsx index dc2ce623e94..0f5409e930d 100644 --- a/packages/react/src/Dialog/Dialog.tsx +++ b/packages/react/src/Dialog/Dialog.tsx @@ -370,7 +370,7 @@ const _Dialog = React.forwardRef