Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/perf-hasinteractivenodes-cleanup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@primer/react': patch
---

perf(hasInteractiveNodes): Optimize with combined selector and early attribute checks

- Use combined querySelectorAll selector instead of recursive traversal
- Check attribute-based states (disabled, hidden, inert) before getComputedStyle
- Only call getComputedStyle when CSS-based visibility check is needed
43 changes: 22 additions & 21 deletions packages/react/src/internal/utils/hasInteractiveNodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ const interactiveElements = interactiveElementsSelectors.map(
selector => `${selector}:not(${Object.values(nonValidSelectors).join('):not(')})`,
)

// Combined selector for fast querySelector check
const interactiveSelector = interactiveElements.join(', ')

/**
* Finds interactive nodes within the passed node.
* If the node itself is interactive, or children within are, it will return true.
Expand All @@ -38,32 +41,30 @@ export function hasInteractiveNodes(node: HTMLElement | null, ignoreNodes?: HTML
// If one does exist, we can abort early.

const nodesToIgnore = ignoreNodes ? [node, ...ignoreNodes] : [node]
const interactiveNodes = findInteractiveChildNodes(node, nodesToIgnore)

return Boolean(interactiveNodes)
// Performance optimization: Use querySelectorAll with combined selector first
// This avoids recursive getComputedStyle calls for each node
const candidates = node.querySelectorAll<HTMLElement>(interactiveSelector)
for (const candidate of candidates) {
if (!nodesToIgnore.includes(candidate) && !isNonValidInteractiveNode(candidate)) {
return true
}
}

return false
}

// Cache for visibility checks to avoid repeated getComputedStyle calls during a single traversal
Comment thread
mattcosta7 marked this conversation as resolved.
Outdated
// Note: Only call getComputedStyle when CSS-based checks are insufficient
function isNonValidInteractiveNode(node: HTMLElement) {
const nodeStyle = getComputedStyle(node)
// Fast path: Check attribute-based states first (no style recalc needed)
const isNonInteractive = node.matches('[disabled], [hidden], [inert]')
const isHiddenVisually = nodeStyle.display === 'none' || nodeStyle.visibility === 'hidden'

return isNonInteractive || isHiddenVisually
}
if (isNonInteractive) return true

function findInteractiveChildNodes(node: HTMLElement | null, ignoreNodes: HTMLElement[]) {
if (!node) return

const ignoreSelector = ignoreNodes.find(elem => elem === node)
const isNotValidNode = isNonValidInteractiveNode(node)

if (node.matches(interactiveElements.join(', ')) && !ignoreSelector && !isNotValidNode) {
return node
}

for (const child of node.children) {
const interactiveNode = findInteractiveChildNodes(child as HTMLElement, ignoreNodes)
// Only call getComputedStyle if attribute checks passed
// This is necessary for display:none and visibility:hidden which aren't detectable via attributes
const nodeStyle = getComputedStyle(node)
const isHiddenVisually = nodeStyle.display === 'none' || nodeStyle.visibility === 'hidden'

if (interactiveNode) return true
}
return isHiddenVisually
}
Loading