diff --git a/.changeset/perf-avatarstack-has-selector.md b/.changeset/perf-avatarstack-has-selector.md new file mode 100644 index 00000000000..cd9f6ea5715 --- /dev/null +++ b/.changeset/perf-avatarstack-has-selector.md @@ -0,0 +1,8 @@ +--- +'@primer/react': patch +--- + +perf(AvatarStack): Optimize CSS :has() selector and throttle MutationObserver + +- Scope `:has([data-square])` to direct child with `>` combinator for O(1) lookup +- Throttle MutationObserver callback using requestAnimationFrame to batch DOM reads diff --git a/packages/react/src/AvatarStack/AvatarStack.module.css b/packages/react/src/AvatarStack/AvatarStack.module.css index b455ec447c8..5adc0fa958e 100644 --- a/packages/react/src/AvatarStack/AvatarStack.module.css +++ b/packages/react/src/AvatarStack/AvatarStack.module.css @@ -143,7 +143,8 @@ box-shadow: 0 0 0 var(--avatar-border-width) transparent; } - &:not([data-component='Avatar']):not(:has([data-square])) { + /* PERFORMANCE: Avatar with data-square is direct child, scope with > for O(1) lookup */ + &:not([data-component='Avatar']):not(:has(> [data-square])) { border-radius: 50%; } diff --git a/packages/react/src/AvatarStack/AvatarStack.tsx b/packages/react/src/AvatarStack/AvatarStack.tsx index ae400a90876..8d67d7249b0 100644 --- a/packages/react/src/AvatarStack/AvatarStack.tsx +++ b/packages/react/src/AvatarStack/AvatarStack.tsx @@ -120,7 +120,19 @@ const AvatarStack = ({ setHasInteractiveChildren(hasInteractiveNodes(stackContainer.current)) } - const observer = new MutationObserver(interactiveChildren) + // Track pending frame to throttle MutationObserver callbacks + let pendingFrame: number | null = null + const throttledInteractiveChildren = () => { + if (pendingFrame !== null) { + cancelAnimationFrame(pendingFrame) + } + pendingFrame = requestAnimationFrame(() => { + pendingFrame = null + interactiveChildren() + }) + } + + const observer = new MutationObserver(throttledInteractiveChildren) observer.observe(stackContainer.current, {childList: true}) @@ -129,6 +141,9 @@ const AvatarStack = ({ return () => { observer.disconnect() + if (pendingFrame !== null) { + cancelAnimationFrame(pendingFrame) + } } } }, [])