Skip to content

Conversation

@mattcosta7
Copy link

@mattcosta7 mattcosta7 commented Dec 14, 2025

This PR optimizes DOM operations across the behaviors library to improve Core Web Vitals metrics, particularly Interaction to Next Paint (INP), Cumulative Layout Shift (CLS), and First Input Delay (FID).

Changes & Expected Impact

1. isFocusable() - Reduced Layout Thrashing

File: src/utils/iterate-focusable-elements.ts

Scenario Before After Improvement
Best case (element fails fast checks) 1 reflow 0 reflows 100% reduction
Average case (strict mode, element passes) 4+ reflows interleaved with logic 3 batched reads ~50% fewer reflows
Worst case (strict mode, all checks needed) 4 reflows + getClientRects Same ops, but batched Eliminates layout thrashing

Changes:

  • Reordered checks to fail fast on cheap operations (hidden, classList, instanceof)
  • Batched all reflow-causing reads (offsetWidth, offsetHeight, offsetParent) together before conditional logic
  • Replaced Array.includes() with Set.has() for O(1) tag lookups
  • Moved expensive getComputedStyle() and getClientRects() calls to run only after cheaper checks pass
  • Bug fix: Now correctly handles position: fixed and position: sticky elements (these have offsetParent === null but are still visible)

2. getClippingRect() / getPositionedParent() - Optimized DOM Traversal

File: src/anchored-position.ts

Scenario Before After Improvement
Best case (parent is document.body) 1 iteration 0 iterations Instant return
Average case (3-level nesting) 3 getComputedStyle calls 3 calls, but early exit possible Cleaner code path
Worst case (deep nesting, 10+ levels) 10+ getComputedStyle calls Same, but exits on first match No wasted iterations

Changes:

  • Added early exit when document.body is reached
  • Simplified loop conditions to reduce per-iteration overhead
  • Batch-parsed all border values in one pass

3. MutationObserver Callback - Batched DOM Operations

File: src/focus-zone.ts

Scenario Before After Improvement
Best case (1 mutation) 1 read + 1 write Same No change
Average case (5 mutations) 5 interleaved read/write cycles 1 read phase + 1 write phase ~80% fewer reflows
Worst case (50+ mutations in batch) 50+ layout thrashing cycles 2 phases (read then write) Dramatic INP improvement

Changes:

  • Separated read phase (collect all elements) from write phase (apply changes)
  • Replaced flatMap + spread operators with direct array push loops to eliminate intermediate array allocations
  • Process all removals before additions to handle element reordering
  • Bug fix: Now correctly checks current attribute state with hasAttribute() instead of relying on mutation.oldValue
  • Optimization: Use Set for automatic deduplication of elements in multiple mutations
  • Bug fix: Added observer.disconnect() on abort to prevent memory leak

4. Focus Trap Sentinels - CLS Prevention

File: src/focus-trap.ts

Scenario Before After Improvement
Best case (sentinels styled externally) 0 CLS 0 CLS No change
Average case (no external styles) Potential CLS on trap activation 0 CLS Eliminates layout shift
Worst case (multiple trap activations) Accumulated CLS 0 CLS Complete CLS prevention

Changes:

  • Added inline sr-only styles to sentinels (position:absolute;clip:rect(0,0,0,0);...)
  • Used direct property assignment (className, tabIndex) instead of setAttribute()
  • Replaced Array.from().filter() with :scope > span.sentinel selector for faster lookup

5. IndexedSet Data Structure - O(1) Membership Checks

File: src/utils/indexed-set.ts (NEW), src/focus-zone.ts

Operation Before (Array) After (IndexedSet) Improvement
Membership check (.has()) O(n) via indexOf O(1) via Set Up to 100x faster for large lists
mousemove handler O(n) scan on every move O(1) fast path Critical for INP - fires 60+ times/sec
focusin handler O(n) O(1) Faster focus handling

Changes:

  • Created IndexedSet<T> class combining array (ordering) + Set (O(1) lookup)
  • Refactored focusableElements from HTMLElement[] to IndexedSet<HTMLElement>
  • All hot paths now use .has() for O(1) membership checks
  • Index-based access uses .get(i) and .size property
  • Maintains iteration order for keyboard navigation

6. Event Listener & Caching Optimizations

File: src/focus-zone.ts

Changes:

  • Cached isMacOS() result to avoid repeated userAgent regex parsing on every keydown event
  • Single character key detection without array allocation (key.length vs [...key].length)

Testing

  • All 75 tests pass (4 new tests added)
  • New tests added for:
    • position: fixed elements are focusable in strict mode
    • position: sticky elements are focusable in strict mode
    • Focus zone does not respond to DOM changes after abort (verifies observer disconnect)
    • Focus trap sentinels have sr-only styles (verifies CLS prevention)

Web Vitals Impact Summary

Metric Expected Improvement
INP Significantly reduced by O(1) membership checks in mousemove/focusin handlers and batching DOM operations
CLS Eliminated by adding sr-only styles to focus trap sentinels
FID Improved by faster event handling and cached computations
TBT Reduced by minimizing main thread blocking in DOM traversals

Memory Optimizations

  • WeakMap for savedTabIndex - allows GC of removed elements
  • MutationObserver disconnect on abort - prevents memory leak
  • Direct array push instead of flatMap/spread - reduces intermediate allocations
  • IndexedSet - small overhead (~2x references) acceptable for O(1) perf gain

Bug Fixes Included

  1. Fixed/sticky positioning - Elements with position: fixed or sticky are now correctly identified as focusable
  2. MutationObserver attribute logic - Now correctly detects when hidden/disabled attributes are added vs removed
  3. Memory leak - MutationObserver is now properly disconnected when focus zone is aborted

Checklist

  • All tests pass (75 total)
  • No breaking changes
  • Code maintains readability with PERFORMANCE comments
  • Performance improvements are measurable in profiling
  • Bug fixes include regression tests
  • New IndexedSet utility is well-documented and reusable'

…web vitals

- Batch reflow-causing reads in isFocusable() to minimize layout thrashing
- Use Set for O(1) tag lookups instead of Array.includes()
- Optimize getClippingRect() and getPositionedParent() with early exits
- Batch MutationObserver DOM operations (read phase, then write phase)
- Add sr-only inline styles to focus-trap sentinels to prevent CLS
- Cache isMacOS() result to avoid repeated userAgent parsing
- Add passive: true to mousemove listener in focus-zone
- Use :scope selector for faster direct-child sentinel lookup
- Replace for...of with indexed for loop in querySelectorAll iteration
@changeset-bot
Copy link

changeset-bot bot commented Dec 14, 2025

🦋 Changeset detected

Latest commit: 581c0b4

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@primer/behaviors Patch

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

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR aims to optimize DOM operations across the behaviors library to improve Core Web Vitals metrics (INP, CLS, FID) by batching reflow-causing reads, using more efficient data structures and selectors, and preventing layout shifts.

Key changes:

  • Refactored isFocusable() to batch layout-triggering property reads and reorder checks for early exits
  • Implemented batched DOM operations in MutationObserver callbacks to separate read and write phases
  • Added inline sr-only styles to focus trap sentinels to prevent CLS

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
src/utils/iterate-focusable-elements.ts Optimizes isFocusable() by using a Set for tag lookups, batching reflow-causing reads, and reordering checks for early exits
src/focus-zone.ts Caches isMacOS() result, refactors MutationObserver to batch DOM operations in read/write phases, and uses indexed for loops
src/focus-trap.ts Adds inline sr-only styles to sentinels for CLS prevention and optimizes sentinel detection with :scope selector
src/anchored-position.ts Adds early exit conditions to getPositionedParent() and getClippingRect() to avoid unnecessary iterations
.changeset/web-vitals-optimizations.md Documents the performance optimizations for the changelog

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

…ogic

- Fixed isFocusable() to not exclude position:fixed/sticky elements (offsetParent is null for these)
- Fixed MutationObserver to check current attribute state instead of relying on oldValue
- Use Sets for deduplication in MutationObserver to avoid processing same element twice
- Added tests for fixed/sticky positioned elements
@mattcosta7 mattcosta7 marked this pull request as ready for review December 14, 2025 17:57
@mattcosta7 mattcosta7 requested a review from a team as a code owner December 14, 2025 17:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants