Skip to content

Commit a8b42b2

Browse files
mattcosta7CopilotCopilotfrancineluccaprimer[bot]
authored
perf(hasInteractiveNodes): Optimize with combined selector and early attribute checks (#7342)
Co-authored-by: Copilot <[email protected]> Co-authored-by: Copilot <[email protected]> Co-authored-by: francinelucca <[email protected]> Co-authored-by: primer[bot] <119360173+primer[bot]@users.noreply.github.com>
1 parent de970d6 commit a8b42b2

File tree

3 files changed

+355
-41
lines changed

3 files changed

+355
-41
lines changed
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@primer/react': patch
3+
---
4+
5+
perf(hasInteractiveNodes): Optimize with combined selector and early attribute checks
6+
7+
- Use combined querySelectorAll selector instead of recursive traversal
8+
- Check attribute-based states (disabled, hidden, inert) before getComputedStyle
9+
- Only call getComputedStyle when CSS-based visibility check is needed

packages/react/src/internal/utils/__tests__/hasInteractiveNodes.test.ts

Lines changed: 325 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,26 +2,30 @@ import {describe, expect, test} from 'vitest'
22
import {hasInteractiveNodes} from '../hasInteractiveNodes'
33

44
describe('hasInteractiveNodes', () => {
5-
test('if there are no interactive nodes', () => {
5+
test('returns false when node is null', () => {
6+
expect(hasInteractiveNodes(null)).toBe(false)
7+
})
8+
9+
test('returns false if there are no interactive nodes', () => {
610
const node = document.createElement('div')
711
expect(hasInteractiveNodes(node)).toBe(false)
812
})
913

10-
test('if there are interactive nodes', () => {
14+
test('returns true if there are interactive nodes', () => {
1115
const node = document.createElement('div')
1216
const button = document.createElement('button')
1317
node.appendChild(button)
1418

1519
expect(hasInteractiveNodes(node)).toBe(true)
1620
})
1721

18-
test('if the node itself is interactive', () => {
22+
test('returns false if the node itself is interactive', () => {
1923
const node = document.createElement('button')
2024

2125
expect(hasInteractiveNodes(node)).toBe(false)
2226
})
2327

24-
test('if there are nested interactive nodes', () => {
28+
test('returns true if there are nested interactive nodes', () => {
2529
const node = document.createElement('div')
2630
const wrapper = document.createElement('div')
2731
const button = document.createElement('button')
@@ -33,28 +37,329 @@ describe('hasInteractiveNodes', () => {
3337
expect(hasInteractiveNodes(node)).toBe(true)
3438
})
3539

36-
test('if the node is disabled', () => {
37-
const node = document.createElement('button')
38-
node.disabled = true
40+
describe('disabled elements', () => {
41+
test('returns false if the node is disabled', () => {
42+
const node = document.createElement('button')
43+
node.disabled = true
3944

40-
expect(hasInteractiveNodes(node)).toBe(false)
45+
expect(hasInteractiveNodes(node)).toBe(false)
46+
})
47+
48+
test('returns false if the child node is disabled', () => {
49+
const node = document.createElement('div')
50+
const button = document.createElement('button')
51+
button.disabled = true
52+
node.appendChild(button)
53+
54+
expect(hasInteractiveNodes(node)).toBe(false)
55+
})
4156
})
4257

43-
test('if the child node is disabled', () => {
44-
const node = document.createElement('div')
45-
const button = document.createElement('button')
46-
button.disabled = true
47-
node.appendChild(button)
58+
describe('tabindex handling', () => {
59+
test('returns true if child node has tabindex="0"', () => {
60+
const node = document.createElement('div')
61+
const span = document.createElement('span')
62+
span.setAttribute('tabindex', '0')
63+
node.appendChild(span)
4864

49-
expect(hasInteractiveNodes(node)).toBe(false)
65+
expect(hasInteractiveNodes(node)).toBe(true)
66+
})
67+
68+
test('returns false if child node has tabindex="-1"', () => {
69+
const node = document.createElement('div')
70+
const span = document.createElement('span')
71+
span.setAttribute('tabindex', '-1')
72+
node.appendChild(span)
73+
74+
expect(hasInteractiveNodes(node)).toBe(false)
75+
})
5076
})
5177

52-
test('if child node has tabindex', () => {
53-
const node = document.createElement('div')
54-
const span = document.createElement('span')
55-
span.setAttribute('tabindex', '0')
56-
node.appendChild(span)
78+
describe('interactive element types', () => {
79+
test('returns true for anchor with href', () => {
80+
const node = document.createElement('div')
81+
const anchor = document.createElement('a')
82+
anchor.href = 'https://example.com'
83+
node.appendChild(anchor)
5784

58-
expect(hasInteractiveNodes(node)).toBe(true)
85+
expect(hasInteractiveNodes(node)).toBe(true)
86+
})
87+
88+
test('returns false for anchor without href', () => {
89+
const node = document.createElement('div')
90+
const anchor = document.createElement('a')
91+
node.appendChild(anchor)
92+
93+
expect(hasInteractiveNodes(node)).toBe(false)
94+
})
95+
96+
test('returns true for button', () => {
97+
const node = document.createElement('div')
98+
const button = document.createElement('button')
99+
node.appendChild(button)
100+
101+
expect(hasInteractiveNodes(node)).toBe(true)
102+
})
103+
104+
test('returns true for summary', () => {
105+
const node = document.createElement('div')
106+
const summary = document.createElement('summary')
107+
node.appendChild(summary)
108+
109+
expect(hasInteractiveNodes(node)).toBe(true)
110+
})
111+
112+
test('returns true for select', () => {
113+
const node = document.createElement('div')
114+
const select = document.createElement('select')
115+
node.appendChild(select)
116+
117+
expect(hasInteractiveNodes(node)).toBe(true)
118+
})
119+
120+
test('returns true for input (not hidden)', () => {
121+
const node = document.createElement('div')
122+
const input = document.createElement('input')
123+
input.type = 'text'
124+
node.appendChild(input)
125+
126+
expect(hasInteractiveNodes(node)).toBe(true)
127+
})
128+
129+
test('returns false for input with type=hidden', () => {
130+
const node = document.createElement('div')
131+
const input = document.createElement('input')
132+
input.type = 'hidden'
133+
node.appendChild(input)
134+
135+
expect(hasInteractiveNodes(node)).toBe(false)
136+
})
137+
138+
test('returns true for textarea', () => {
139+
const node = document.createElement('div')
140+
const textarea = document.createElement('textarea')
141+
node.appendChild(textarea)
142+
143+
expect(hasInteractiveNodes(node)).toBe(true)
144+
})
145+
146+
test('returns true for audio with controls', () => {
147+
const node = document.createElement('div')
148+
const audio = document.createElement('audio')
149+
audio.controls = true
150+
node.appendChild(audio)
151+
152+
expect(hasInteractiveNodes(node)).toBe(true)
153+
})
154+
155+
test('returns false for audio without controls', () => {
156+
const node = document.createElement('div')
157+
const audio = document.createElement('audio')
158+
node.appendChild(audio)
159+
160+
expect(hasInteractiveNodes(node)).toBe(false)
161+
})
162+
163+
test('returns true for video with controls', () => {
164+
const node = document.createElement('div')
165+
const video = document.createElement('video')
166+
video.controls = true
167+
node.appendChild(video)
168+
169+
expect(hasInteractiveNodes(node)).toBe(true)
170+
})
171+
172+
test('returns false for video without controls', () => {
173+
const node = document.createElement('div')
174+
const video = document.createElement('video')
175+
node.appendChild(video)
176+
177+
expect(hasInteractiveNodes(node)).toBe(false)
178+
})
179+
180+
test('returns true for contenteditable element', () => {
181+
const node = document.createElement('div')
182+
const editable = document.createElement('div')
183+
editable.contentEditable = 'true'
184+
node.appendChild(editable)
185+
186+
expect(hasInteractiveNodes(node)).toBe(true)
187+
})
188+
})
189+
190+
describe('hidden and inert elements', () => {
191+
test('returns false for element with hidden attribute', () => {
192+
const node = document.createElement('div')
193+
const button = document.createElement('button')
194+
button.hidden = true
195+
node.appendChild(button)
196+
197+
expect(hasInteractiveNodes(node)).toBe(false)
198+
})
199+
200+
test('returns false for element with inert attribute', () => {
201+
const node = document.createElement('div')
202+
const button = document.createElement('button')
203+
button.setAttribute('inert', '')
204+
node.appendChild(button)
205+
206+
expect(hasInteractiveNodes(node)).toBe(false)
207+
})
208+
})
209+
210+
describe('CSS visibility', () => {
211+
test('returns false for element with display:none', () => {
212+
const node = document.createElement('div')
213+
const button = document.createElement('button')
214+
button.style.display = 'none'
215+
node.appendChild(button)
216+
document.body.appendChild(node)
217+
218+
expect(hasInteractiveNodes(node)).toBe(false)
219+
220+
document.body.removeChild(node)
221+
})
222+
223+
test('returns false for element with visibility:hidden', () => {
224+
const node = document.createElement('div')
225+
const button = document.createElement('button')
226+
button.style.visibility = 'hidden'
227+
node.appendChild(button)
228+
document.body.appendChild(node)
229+
230+
expect(hasInteractiveNodes(node)).toBe(false)
231+
232+
document.body.removeChild(node)
233+
})
234+
235+
test('returns true for element with visibility:visible', () => {
236+
const node = document.createElement('div')
237+
const button = document.createElement('button')
238+
button.style.visibility = 'visible'
239+
node.appendChild(button)
240+
document.body.appendChild(node)
241+
242+
expect(hasInteractiveNodes(node)).toBe(true)
243+
244+
document.body.removeChild(node)
245+
})
246+
})
247+
248+
describe('ignoreNodes parameter', () => {
249+
test('ignores nodes in ignoreNodes array', () => {
250+
const node = document.createElement('div')
251+
const button1 = document.createElement('button')
252+
const button2 = document.createElement('button')
253+
node.appendChild(button1)
254+
node.appendChild(button2)
255+
256+
expect(hasInteractiveNodes(node, [button1, button2])).toBe(false)
257+
})
258+
259+
test('returns true if there are interactive nodes not in ignoreNodes', () => {
260+
const node = document.createElement('div')
261+
const button1 = document.createElement('button')
262+
const button2 = document.createElement('button')
263+
node.appendChild(button1)
264+
node.appendChild(button2)
265+
266+
expect(hasInteractiveNodes(node, [button1])).toBe(true)
267+
})
268+
269+
test('always ignores the node itself', () => {
270+
const node = document.createElement('button')
271+
const childButton = document.createElement('button')
272+
node.appendChild(childButton)
273+
274+
expect(hasInteractiveNodes(node)).toBe(true)
275+
})
276+
})
277+
278+
describe('performance optimizations', () => {
279+
test('handles large DOM trees efficiently', () => {
280+
const node = document.createElement('div')
281+
282+
// Create a large tree with multiple levels
283+
for (let i = 0; i < 100; i++) {
284+
const wrapper = document.createElement('div')
285+
const span = document.createElement('span')
286+
wrapper.appendChild(span)
287+
node.appendChild(wrapper)
288+
}
289+
290+
// Add one interactive node at the end
291+
const button = document.createElement('button')
292+
node.appendChild(button)
293+
294+
expect(hasInteractiveNodes(node)).toBe(true)
295+
})
296+
297+
test('stops early when first interactive node is found', () => {
298+
const node = document.createElement('div')
299+
300+
// Add interactive node at the beginning
301+
const button = document.createElement('button')
302+
node.appendChild(button)
303+
304+
// Add many more elements after
305+
for (let i = 0; i < 100; i++) {
306+
const div = document.createElement('div')
307+
node.appendChild(div)
308+
}
309+
310+
expect(hasInteractiveNodes(node)).toBe(true)
311+
})
312+
})
313+
314+
describe('complex scenarios', () => {
315+
test('handles multiple types of interactive elements', () => {
316+
const node = document.createElement('div')
317+
const anchor = document.createElement('a')
318+
anchor.href = '#'
319+
const button = document.createElement('button')
320+
const input = document.createElement('input')
321+
322+
node.appendChild(anchor)
323+
node.appendChild(button)
324+
node.appendChild(input)
325+
326+
expect(hasInteractiveNodes(node)).toBe(true)
327+
})
328+
329+
test('handles deeply nested structure', () => {
330+
const node = document.createElement('div')
331+
let current = node
332+
333+
// Create deep nesting
334+
for (let i = 0; i < 10; i++) {
335+
const wrapper = document.createElement('div')
336+
current.appendChild(wrapper)
337+
current = wrapper
338+
}
339+
340+
// Add button at the deepest level
341+
const button = document.createElement('button')
342+
current.appendChild(button)
343+
344+
expect(hasInteractiveNodes(node)).toBe(true)
345+
})
346+
347+
test('correctly handles mix of valid and invalid interactive elements', () => {
348+
const node = document.createElement('div')
349+
350+
const disabledButton = document.createElement('button')
351+
disabledButton.disabled = true
352+
353+
const hiddenButton = document.createElement('button')
354+
hiddenButton.hidden = true
355+
356+
const validButton = document.createElement('button')
357+
358+
node.appendChild(disabledButton)
359+
node.appendChild(hiddenButton)
360+
node.appendChild(validButton)
361+
362+
expect(hasInteractiveNodes(node)).toBe(true)
363+
})
59364
})
60365
})

0 commit comments

Comments
 (0)