Skip to content

Conversation

@kennethkalmer
Copy link
Member

@kennethkalmer kennethkalmer commented Dec 9, 2025

Jira Ticket Link / Motivation

WEB-4845 - Performance optimization to eliminate iOS device overheating

Our website (ably.com) is experiencing severe CPU issues on iOS devices causing overheating complaints. Chrome DevTools Performance profiling identified two critical bottlenecks:

  1. Header component: 71-193ms of forced reflows per scroll event
  2. Expander component: 67-92ms of forced reflows (discovered after fixing Header)

Both components were using synchronous layout queries (getBoundingClientRect(), clientHeight) that force the browser to recalculate layout on every interaction.

Summary of Changes

This PR implements performance optimizations for both components using modern browser APIs, plus comprehensive documentation for future performance work.

Header Optimization (useThemedScrollpoints)

New Files:

  • src/core/hooks/use-themed-scrollpoints.ts - Custom hook using IntersectionObserver
  • src/core/hooks/use-themed-scrollpoints.test.ts - Comprehensive unit tests (15 tests)
  • src/core/Header/types.ts - Extracted ThemedScrollpoint type

Modified Files:

  • src/core/Header.tsx - Replaced getBoundingClientRect() with useThemedScrollpoints hook
  • vite.config.mts - Added new hook test to suite
  • package.json / pnpm-lock.yaml - Added @testing-library/react dev dependency

Performance Impact:

  • Before: 71-193ms of forced reflows per scroll
  • After: <2ms overhead
  • 98% improvement

Bug Fixes:

  • Fixed initial state detection (IntersectionObserver only fires on changes, not mount)
  • Fixed intersection tracking (now tracks all intersecting elements, not just changes)
  • Uses "closest to header position" logic for accurate theme detection
  • Prevents test pollution by saving/restoring global mocks
  • Clears state when scrollpoints becomes empty
  • Added tiebreaker for equal-distance elements (fixes Voltaire meganav border bug)

Expander Optimization (useContentHeight)

New Files:

  • src/core/hooks/use-content-height.ts - Custom hook using ResizeObserver

Modified Files:

  • src/core/Expander.tsx - Replaced clientHeight with useContentHeight hook
  • Removed throttled resize listener
  • Added useMemo for performance

Performance Impact:

  • Before: 67-92ms of forced reflows
  • After: <5ms overhead
  • 94% improvement

Pages Affected:

  • /chat - Was 67ms reflows (52% of total) → Now <5ms
  • /pubsub - Was 92ms reflows (96% of total) → Now <5ms

Documentation

Planning Docs:

  • docs/plans/header-performance-optimization.md - Header optimization requirements and analysis
  • docs/plans/expander-performance-optimization.md - Expander optimization requirements and analysis

Agent Development Guide:

  • AGENTS.md - Added comprehensive performance optimization section (337 lines):
    • When to optimize (DevTools profiling indicators)
    • IntersectionObserver pattern with complete examples
    • ResizeObserver pattern with RAF cleanup
    • Testing async hooks with proper mock restoration
    • Common pitfalls checklist (8 items)
    • Storybook performance testing workflow
    • Custom hooks documentation guidelines

These docs capture patterns, pitfalls, and best practices discovered during this work to help future agents and developers avoid common issues.

How to Manually Test

1. Performance Testing (Chrome DevTools)

pnpm storybook

Header Test:

  1. Open DevTools → Performance tab
  2. Navigate to "Header > With Themed Scrollpoints"
  3. Record while scrolling
  4. Verify zero getBoundingClientRect calls in scroll handler
  5. Verify no "Forced reflow" warnings
  6. Scroll handler overhead should be <2ms (down from 71-193ms)

Expander Test:

  1. Navigate to "Expander > Default"
  2. Record while expanding/collapsing
  3. Verify zero clientHeight reads
  4. Verify <5ms overhead

2. Functional Testing

Themed Scrollpoints:

  • Load "Header > With Themed Scrollpoints"
  • Header should be transparent on load (showing orange gradient)
  • Scroll down → header becomes white background
  • Scroll to dark zone → header becomes dark
  • Scroll back to top → header becomes transparent again

Expander:

  • All expand/collapse animations should work smoothly
  • Dynamic content changes should be handled
  • Nested expanders should work (if applicable)

3. Run Tests

pnpm test

All 206 tests should pass.

Key Optimizations

  1. IntersectionObserver API - Async intersection detection (Header)
  2. ResizeObserver API - Async height tracking (Expander)
  3. requestAnimationFrame batching - Batch state updates
  4. Cached DOM references - Query once on mount
  5. Passive scroll listeners - iOS performance boost
  6. Change detection - Only update state when values change
  7. useMemo optimization - Prevent unnecessary recalculations

Testing

  • ✅ All 206 tests passing (15 new tests for useThemedScrollpoints, 1 new test for empty scrollpoints)
  • ✅ Backward compatible - no API changes
  • ✅ TypeScript compilation successful
  • ✅ Linting passes
  • ✅ Test pollution prevented with proper mock cleanup

Merge/Deploy Checklist

  • Written automated tests
  • Commits have clear descriptions
  • Checked for performance regressions (massive improvements, no regressions)
  • Planning docs organized in docs/plans/
  • AGENTS.md updated with patterns and best practices

Notes

  • Version bumped to 17.13.0 for pre-release testing in Voltaire
  • Pre-release version: 17.13.0-dev.0daff736
  • Planning docs and AGENTS.md updates will help prevent similar issues in future work
  • No breaking changes - fully backward compatible

Summary by CodeRabbit

  • Refactor

    • Optimized Expander component state management and height calculations.
    • Refactored Header component scroll detection and visibility logic.
    • Introduced new internal hooks for improved component behavior.
  • Tests

    • Added comprehensive test suite for scroll detection functionality.
  • Documentation

    • Added performance optimization plans for Expander and Header components.
    • Enhanced AGENTS.md with performance optimization patterns and best practices.
  • Chores

    • Updated project version and development dependencies.

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Dec 9, 2025

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

  • 🔍 Trigger a full review

Walkthrough

This PR introduces performance optimizations for Header and Expander components by implementing two new custom hooks (useContentHeight and useThemedScrollpoints) that replace manual DOM queries with observer-based approaches. The Header's ThemedScrollpoint type is extracted to a dedicated file, and comprehensive tests are added alongside planning documentation.

Changes

Cohort / File(s) Summary
Performance Hooks
src/core/hooks/use-content-height.ts, src/core/hooks/use-themed-scrollpoints.ts
New custom hooks: useContentHeight monitors element content height via ResizeObserver with requestAnimationFrame batching; useThemedScrollpoints derives active CSS class names using IntersectionObserver with rootMargin tuned to header height.
Themed Scrollpoints Tests
src/core/hooks/use-themed-scrollpoints.test.ts
Comprehensive test suite (537 lines) covering mount behavior, empty scrollpoints, observer creation, DOM element observance, warning logs, class name updates, closest-element logic, and lifecycle cleanup.
Header Refactoring
src/core/Header.tsx, src/core/Header/types.ts
Extracted ThemedScrollpoint type to dedicated external file; introduced useThemedScrollpoints hook to replace manual scrollpoint DOM checks and state mutations; simplified scroll visibility logic using previousVisibility mechanism to reduce redundant updates.
Expander Refactoring
src/core/Expander.tsx
Replaced manual height-tracking state with useContentHeight hook; removed useEffect-driven initialization and window resize listener; replaced showControls state with memoized derivation; replaced useEffect with useMemo where appropriate.
Manifest & Config
package.json, public/mockServiceWorker.js, vite.config.mts
Version bumps: package 17.12.0 → 17.13.0-dev.5fce2b67, MSW 2.11.1 → 2.12.0; MSW removes MOCK_DEACTIVATE handling, adds requestInterceptedAt timestamp threading; test config renamed "insights" to "unit", includes new hook test.
Documentation & Stories
docs/plans/expander-performance-optimization.md, docs/plans/header-performance-optimization.md, src/core/Header/Header.stories.tsx
Planning documents detail performance bottlenecks, proposed solutions (CSS-only, ResizeObserver, CSS Grid), and optimization targets; storybook story restructured with hero/main wrapper pattern and updated scrollpoint IDs.

Sequence Diagram(s)

sequenceDiagram
    actor User as User (scrolling)
    participant Header as Header Component
    participant IO as IntersectionObserver
    participant RAF as requestAnimationFrame
    participant DOM as Scrollpoint Elements
    participant State as Active Class State

    User->>DOM: Scroll near scrollpoints
    DOM->>IO: Elements enter/exit viewport
    IO->>IO: Calc closest to header (64px margin)
    IO->>RAF: Schedule state update
    RAF->>State: Update activeClassName
    State->>Header: Re-render with new class
    Header->>User: Visual feedback (styled scrollpoint)
Loading
sequenceDiagram
    actor Component as Expander Component
    participant RO as ResizeObserver
    participant RAF as requestAnimationFrame
    participant State as contentHeight State
    participant Render as Component Render

    Component->>RO: Observe inner element
    Component->>RO: Mount complete
    Note over RO: Content resizes
    RO->>RAF: Schedule height read
    RAF->>State: Update contentHeight (rounded)
    State->>Render: Trigger re-render
    Render->>Component: Recompute memoized height & showControls
Loading

Estimated Code Review Effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 Hooks of observation, scrolling with grace,
ResizeObserver watches each inner space,
Intersection magic detects where we are,
No more DOM queries—we're faster by far!
Performance blooms when observers align.

🚥 Pre-merge checks | ✅ 4 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 75.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: eliminating forced reflows in Header and Expander components, which aligns perfectly with the primary objective of the PR.
Linked Issues check ✅ Passed The PR fully addresses WEB-4845's objectives: identifies iPhone performance root causes (forced reflows), implements fixes using IntersectionObserver and ResizeObserver, achieves 94-98% performance improvements, maintains backward compatibility, and includes comprehensive testing.
Out of Scope Changes check ✅ Passed All changes are directly aligned with WEB-4845 objectives. Documentation files (docs/plans/) and version bumps support the optimization work; testing updates and type extraction (Header/types.ts) are necessary refactoring; MockServiceWorker.js changes support testing infrastructure.
Description check ✅ Passed The pull request description is comprehensive, well-structured, and includes all required sections from the template.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

@kennethkalmer kennethkalmer force-pushed the WEB-4845-performance-fixes branch 2 times, most recently from 7607085 to 9afd9d0 Compare January 23, 2026 15:51
@kennethkalmer kennethkalmer changed the title perf: Optimize Header scroll performance with IntersectionObserver [WEB-4845] Optimize Header scroll performance with IntersectionObserver Jan 26, 2026
@kennethkalmer kennethkalmer force-pushed the WEB-4845-performance-fixes branch from 57304a4 to 65e4310 Compare January 26, 2026 21:58
@kennethkalmer kennethkalmer changed the title [WEB-4845] Optimize Header scroll performance with IntersectionObserver perf: Eliminate forced reflows in Header and Expander components Jan 26, 2026
@kennethkalmer kennethkalmer changed the title perf: Eliminate forced reflows in Header and Expander components [WEB-4845] Eliminate forced reflows in Header and Expander components Jan 26, 2026
@kennethkalmer kennethkalmer marked this pull request as ready for review January 26, 2026 22:00
@kennethkalmer kennethkalmer requested a review from a team as a code owner January 26, 2026 22:00
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In `@fix-ably-ui.md`:
- Line 11: The review points out a stale filename reference to Header; update
all references from src/core/Header.js to src/core/Header.tsx (including
imports, documentation, comments, and any error/stack trace annotations) so they
match the actual Header component; search for occurrences of "Header.js" and
replace them with "Header.tsx" and verify the Header component export name
(Header) is unchanged so imports like import Header from 'src/core/Header'
resolve to the .tsx file.

In `@src/core/hooks/use-themed-scrollpoints.test.ts`:
- Around line 10-43: The test suite currently overwrites
global.IntersectionObserver and global.requestAnimationFrame in beforeEach
(assigning via the mocked factory that captures observerCallback and sets
mockObserve/mockUnobserve/mockDisconnect) but never restores the originals,
causing cross-file test pollution; update the file to save the originals (e.g.,
const originalIntersectionObserver = global.IntersectionObserver and const
originalRequestAnimationFrame = global.requestAnimationFrame) before mocking in
beforeEach and restore them in afterEach (reassign global.IntersectionObserver =
originalIntersectionObserver and global.requestAnimationFrame =
originalRequestAnimationFrame), keeping the existing mock variables
(observerCallback, mockObserve, mockUnobserve, mockDisconnect) intact.

In `@src/core/hooks/use-themed-scrollpoints.ts`:
- Around line 18-21: In the use-themed-scrollpoints hook, handle the case where
scrollpoints becomes empty by clearing any active class/state: inside the
useEffect that currently returns when scrollpoints.length === 0, instead of
returning immediately, remove the active class from the previously active
element (via the same ref or query used elsewhere), reset the active index/state
(e.g., activeIndex or activeRef) to null/undefined, and clear any stored
references/timers used by functions like setActiveClass or onScroll; update the
cleanup so the hook leaves no stale theming when scrollpoints goes from
non-empty to empty.
🧹 Nitpick comments (4)
src/core/Expander.tsx (1)

69-80: Add focus-base class for keyboard accessibility.

As per coding guidelines, interactive elements should include the focus-base class for consistent focus states.

♻️ Proposed fix
           <button
             data-testid="expander-controls"
             className={cn(
               heightThreshold === 0 && !expanded ? "" : "mt-4",
-              "cursor-pointer font-bold text-gui-blue-default-light hover:text-gui-blue-hover-light",
+              "cursor-pointer font-bold text-gui-blue-default-light hover:text-gui-blue-hover-light focus-base transition-colors",
               controlsClassName,
             )}
           >
vite.config.mts (1)

20-23: Consider adding tests for useContentHeight hook.

The use-themed-scrollpoints.test.ts is included, but there's no corresponding test file for the new useContentHeight hook. Given that this hook is critical for the Expander performance fix, test coverage would help ensure reliability.

Would you like me to help generate unit tests for the useContentHeight hook, or open an issue to track this?

src/core/hooks/use-themed-scrollpoints.ts (1)

1-4: Reuse the shared HEADER_HEIGHT constant.

Keeping a local 64 risks drift if the header height changes; importing the shared constant keeps the hook aligned with Header.

Proposed refactor
-import { useState, useEffect, useRef } from "react";
-import { ThemedScrollpoint } from "../Header/types";
-
-const HEADER_HEIGHT = 64;
+import { useState, useEffect, useRef } from "react";
+import { ThemedScrollpoint } from "../Header/types";
+import { HEADER_HEIGHT } from "../utils/heights";
src/core/Header.tsx (1)

250-259: Cancel the throttled scroll handler on cleanup.

The es-toolkit/compat throttle function attaches a cancel() method to prevent trailing calls from firing after the listener is removed. Without this, a scheduled trailing invocation could fire after unmount and trigger state updates.

Proposed refactor
    return () => {
      window.removeEventListener("scroll", throttledHandleScroll);
+     throttledHandleScroll.cancel();
    };

@kennethkalmer kennethkalmer force-pushed the WEB-4845-performance-fixes branch 2 times, most recently from c231305 to 026fa7c Compare January 26, 2026 23:05
@kennethkalmer kennethkalmer self-assigned this Jan 26, 2026
@kennethkalmer kennethkalmer force-pushed the WEB-4845-performance-fixes branch from 4394cbf to fe01643 Compare January 26, 2026 23:16
Copy link
Contributor

@aralovelace aralovelace left a comment

Choose a reason for hiding this comment

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

@kennethkalmer this is really great Ken! I only notice that the border under Meganav when it scrollback to the top its not disappearing anymore?

https://www.loom.com/share/2582c98b7e244ee6836045f5d316d661

Copy link
Contributor

@aralovelace aralovelace left a comment

Choose a reason for hiding this comment

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

Copy link
Contributor

@aralovelace aralovelace left a comment

Choose a reason for hiding this comment

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

I also notice in the website app its not coming back to top nav version as well

https://www.loom.com/share/7434418786b1496eb6b58cdde5efc4bc

@kennethkalmer kennethkalmer force-pushed the WEB-4845-performance-fixes branch 2 times, most recently from f5b53f1 to 5fce2b6 Compare January 27, 2026 17:25
@kennethkalmer
Copy link
Member Author

@aralovelace thank you for the great reviews and testing, I really really appreciate it. I think I've fixed it now, in Safari on the Voltaire review I can see the navigation behave correctly now. Mind giving it another spin?

@kennethkalmer
Copy link
Member Author

@coderabbitai full review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Jan 27, 2026

✅ Actions performed

Full review triggered.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@src/core/hooks/use-content-height.ts`:
- Around line 26-34: The requestAnimationFrame callback inside the
ResizeObserver can run after unmount and call setContentHeight on an unmounted
component; fix by capturing the RAF id returned from requestAnimationFrame (and
the observer) in scope when scheduling, canceling that RAF in the hook cleanup
(via cancelAnimationFrame) and disconnecting observerRef
(observerRef.current.disconnect()), and optionally guard the callback with a
mounted flag before calling setContentHeight; update the code around
observerRef, ResizeObserver, requestAnimationFrame, and setContentHeight to
ensure cancellation/guarding in cleanup.
🧹 Nitpick comments (7)
src/core/hooks/use-content-height.ts (1)

42-42: ref as dependency won't trigger re-runs when ref.current changes.

The RefObject itself is stable across renders, so changes to ref.current won't cause the effect to re-run. This is fine if the ref is attached to a stable element, but if the element changes (e.g., conditional rendering), the observer won't update. Consider documenting this limitation or using a callback ref pattern if dynamic element changes are expected.

src/core/Expander.tsx (1)

69-80: Add focus-base and transition-colors classes to the interactive button.

Per coding guidelines, interactive elements should include the focus-base class for consistent focus styling and transition-colors for hover state transitions.

♻️ Proposed fix
           <button
             data-testid="expander-controls"
             className={cn(
               heightThreshold === 0 && !expanded ? "" : "mt-4",
-              "cursor-pointer font-bold text-gui-blue-default-light hover:text-gui-blue-hover-light",
+              "cursor-pointer font-bold text-gui-blue-default-light hover:text-gui-blue-hover-light focus-base transition-colors",
               controlsClassName,
             )}
           >
src/core/Header/types.ts (1)

1-4: Consider adding JSDoc documentation for the public type.

Since ThemedScrollpoint is part of the public API (re-exported from Header.tsx), adding JSDoc would improve developer experience and align with coding guidelines.

📝 Suggested documentation
+/**
+ * Represents a themed scrollpoint that triggers header appearance changes
+ * when the element with the specified id scrolls into view.
+ */
 export type ThemedScrollpoint = {
+  /** The DOM element id to observe */
   id: string;
+  /** CSS classes to apply to the header when this scrollpoint is active */
   className: string;
 };
src/core/hooks/use-themed-scrollpoints.test.ts (1)

508-536: Minor: rAF spy created after mock is already in place.

The requestAnimationFrame is already mocked in beforeEach, so spying on it at line 520 after the hook is rendered works but could be clearer if the spy was set up before triggering the callback.

💡 Alternative approach
   it("uses requestAnimationFrame for state updates", () => {
     const elem = document.createElement("div");
     elem.id = "zone1";
     document.body.appendChild(elem);
 
     const scrollpoints = [
       { id: "zone1", className: "theme-light" },
       { id: "zone2", className: "theme-dark" },
     ];
 
+    const rafSpy = vi.spyOn(global, "requestAnimationFrame");
+
     renderHook(() => useThemedScrollpoints(scrollpoints));
 
-    const rafSpy = vi.spyOn(global, "requestAnimationFrame");
-
     // Simulate intersection
     observerCallback(
       [
         {
           target: elem,
           isIntersecting: true,
         } as unknown as IntersectionObserverEntry,
       ],
       {} as IntersectionObserver,
     );
 
     expect(rafSpy).toHaveBeenCalled();
 
     rafSpy.mockRestore();
   });
src/core/hooks/use-themed-scrollpoints.ts (2)

4-4: Consider importing HEADER_HEIGHT from the shared constants.

HEADER_HEIGHT is already defined in src/core/utils/heights.ts (as shown in the relevant code snippets). Duplicating the constant creates a maintenance risk if the value changes.

♻️ Proposed fix
 import { useState, useEffect, useRef } from "react";
 import { ThemedScrollpoint } from "../Header/types";
+import { HEADER_HEIGHT } from "../utils/heights";

-const HEADER_HEIGHT = 64;

58-60: Minor: Defensive fallback for boundingClientRect.

The fallback to getBoundingClientRect() is defensive coding. Per the IntersectionObserver spec, boundingClientRect is always populated on entries. This fallback adds a synchronous layout read that the PR aims to eliminate, though it should rarely (if ever) be triggered.

Consider removing the fallback or adding a comment explaining when it might be needed:

-            const rect =
-              entry.boundingClientRect || entry.target.getBoundingClientRect();
+            // boundingClientRect is always available per IntersectionObserver spec
+            const rect = entry.boundingClientRect;
src/core/Header.tsx (1)

233-259: previousVisibility resets on each effect run due to noticeBannerVisible dependency.

The previousVisibility variable is a closure that gets re-initialized to noticeBannerVisible whenever the effect re-runs. Since noticeBannerVisible is in the dependency array (line 259), changing visibility causes the effect to re-run, potentially skipping the deduplication check on the first scroll after the change.

Consider using a ref to persist across re-runs:

♻️ Proposed fix using ref
+  const previousVisibilityRef = useRef(noticeBannerVisible);
+
   useEffect(() => {
     if (!isNoticeBannerEnabled) {
       return;
     }

     const noticeElement = document.querySelector('[data-id="ui-notice"]');

     if (!noticeElement) {
       console.warn("Header: Notice element not found");
       return;
     }

-    let previousVisibility = noticeBannerVisible;
-
     const handleScroll = () => {
       const scrollY = window.scrollY;
       const isNoticeHidden = noticeElement.classList.contains(
         "ui-announcement-hidden",
       );

       const shouldBeVisible =
         scrollY <= COLLAPSE_TRIGGER_DISTANCE && !isNoticeHidden;

-      if (shouldBeVisible !== previousVisibility) {
-        previousVisibility = shouldBeVisible;
+      if (shouldBeVisible !== previousVisibilityRef.current) {
+        previousVisibilityRef.current = shouldBeVisible;
         setNoticeBannerVisible(shouldBeVisible);
       }
     };
     // ...rest unchanged
-  }, [isNoticeBannerEnabled, noticeBannerVisible]);
+  }, [isNoticeBannerEnabled]);

@kennethkalmer kennethkalmer force-pushed the WEB-4845-performance-fixes branch 2 times, most recently from 02eb191 to 0daff73 Compare January 28, 2026 09:40
@kennethkalmer kennethkalmer force-pushed the WEB-4845-performance-fixes branch from d8ec1f1 to 6b2fa71 Compare January 28, 2026 10:15
@kennethkalmer kennethkalmer removed the request for review from aralovelace January 28, 2026 10:30
@kennethkalmer
Copy link
Member Author

@aralovelace I see there are still some issues with the themed scroll points, let me resolve them properly first

@kennethkalmer kennethkalmer force-pushed the WEB-4845-performance-fixes branch from 6b2fa71 to 9095ee3 Compare January 28, 2026 12:24
kennethkalmer and others added 6 commits January 28, 2026 12:25
Replace expensive getBoundingClientRect() calls with IntersectionObserver API
to eliminate forced reflows during scroll handling.

- `src/core/hooks/use-themed-scrollpoints.ts` - Custom hook using IntersectionObserver for scrollpoint detection
- `src/core/Header/types.ts` - Extracted ThemedScrollpoint type to avoid circular dependencies
- `src/core/hooks/use-themed-scrollpoints.test.ts` - Comprehensive unit tests for the new hook

- `src/core/Header.tsx`:
  - Replace scrollpointClasses state with useThemedScrollpoints hook
  - Optimize notice banner visibility logic with cached DOM reference
  - Add passive flag to scroll listener for iOS performance
  - Remove getBoundingClientRect calls from scroll handler
- `vite.config.mts` - Add new hook test to unit test suite
- `package.json` / `pnpm-lock.yaml` - Add @testing-library/react dev dependency

- **Before**: 71-193ms of forced reflows per scroll event
- **After**: <2ms overhead (98% improvement)
- **Eliminated**: All getBoundingClientRect calls during scroll
- **Added**: Passive scroll listener for iOS optimization

1. **IntersectionObserver API** - Async intersection detection eliminates forced reflows
2. **Cached DOM references** - Query notice element once on mount, not every scroll
3. **Passive scroll listener** - `{ passive: true }` flag for iOS performance
4. **Change detection** - Only update state when values actually change
5. **requestAnimationFrame batching** - Batch IntersectionObserver callbacks

- 14 new unit tests for useThemedScrollpoints hook (all passing)
- All existing tests pass
- Backward compatible - no API changes to HeaderProps or ThemedScrollpoint type

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 (1M context) <[email protected]>
Document the analysis and planning for Header and Expander performance
optimizations that eliminate forced reflows causing iOS device overheating.

Files:
- docs/plans/header-performance-optimization.md
- docs/plans/expander-performance-optimization.md

These docs capture the original requirements, problem analysis, and
proposed solutions for future reference.

Co-Authored-By: Claude Sonnet 4.5 (1M context) <[email protected]>
Replace synchronous clientHeight reads with ResizeObserver API to eliminate
forced reflows during expand/collapse and resize events.

Changes:
- Created useContentHeight hook using ResizeObserver
- Replaced manual clientHeight reads in Expander component
- Removed throttled resize listener (ResizeObserver handles this)
- Added useMemo for showControls and height calculations

Performance Impact:
- Before: 67-92ms of forced reflows
- After: <5ms overhead (94% improvement)

Co-Authored-By: Claude Sonnet 4.5 (1M context) <[email protected]>
Documents Observer API patterns (IntersectionObserver, ResizeObserver), testing strategies, and common pitfalls from Header/Expander performance work. Includes custom hook guidelines, Storybook performance testing workflow, and checklist for async hook development.

Co-Authored-By: Claude Sonnet 4.5 (1M context) <[email protected]>
Co-Authored-By: Claude Sonnet 4.5 (1M context) <[email protected]>
@kennethkalmer kennethkalmer force-pushed the WEB-4845-performance-fixes branch from 9095ee3 to 06163b6 Compare January 28, 2026 12:25
@kennethkalmer
Copy link
Member Author

@aralovelace I think I nailed it down, tested with Storybook in Chrome, Safari & Firefox and the themed scrollpoints behaved much better.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

3 participants