Skip to content

fix: eliminate HeaderBase layout flicker with useLayoutEffect#25929

Closed
georgewrmarshall wants to merge 2 commits into
mainfrom
header-base-flicker-fix
Closed

fix: eliminate HeaderBase layout flicker with useLayoutEffect#25929
georgewrmarshall wants to merge 2 commits into
mainfrom
header-base-flicker-fix

Conversation

@georgewrmarshall
Copy link
Copy Markdown
Contributor

@georgewrmarshall georgewrmarshall commented Feb 10, 2026

Description

This PR fixes visible layout flicker in the HeaderBase component by replacing onLayout callbacks with useLayoutEffect + View.measure() pattern for measuring accessory widths.

Problem: HeaderBase currently uses onLayout callbacks to measure accessory widths for centering the title. This causes a two-stage layout process:

  1. First render with unknown widths (0)
  2. onLayout fires, updates state with measured widths
  3. Second render with calculated equal wrapper widths
  4. Result: Visible flicker/layout jump between renders

Solution: Switch to useLayoutEffect with View.measure() pattern (similar to useFeedScrollManager.ts). This measures dimensions synchronously before the browser paint, eliminating the visible two-stage layout.

Changelog

CHANGELOG entry: Fixed layout flicker in HeaderBase component during initial render

Related issues

Fixes: (add issue number if available)

Manual testing steps

Feature: HeaderBase layout flicker fix

  Scenario: user views screens with HeaderBase component
    Given the app is running with various screens using HeaderBase
    
    When user navigates to screens with different header configurations:
      - Only start accessory (back button)
      - Only end accessory (close button)
      - Both start and end accessories
      - Multiple end buttons
      - Long title text that wraps
      - Both Compact and Display variants
    
    Then the header should render without visible layout flicker
    And the title should remain properly centered
    And accessories should display correctly

Screenshots/Recordings

Before

After

Pre-merge author checklist

Pre-merge reviewer checklist

  • I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed).
  • I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots.

Technical Details

Changes Made:

  1. Updated imports: Added useRef and useLayoutEffect, removed useCallback and LayoutChangeEvent
  2. Added refs: startAccessoryRef and endAccessoryRef for measuring accessory widths
  3. Added useLayoutEffect: Measures accessory widths synchronously before paint
  4. Removed callback handlers: Deleted handleStartAccessoryLayout and handleEndAccessoryLayout
  5. Updated Views: Replaced onLayout callbacks with refs

Why This Eliminates Flicker:

  • useLayoutEffect runs synchronously after DOM mutations but before browser paint
  • Measurements happen in the same frame as initial render
  • State updates occur before the component is visible to users
  • Avoids the two-stage layout process that caused visible flicker

Note

Medium Risk
Touches a shared UI primitive’s layout measurement logic; measure()/useLayoutEffect timing can vary by platform and could impact header centering or cause extra renders if widths churn.

Overview
Reduces visible title-centering flicker in HeaderBase by switching width calculation for the start/end accessory wrappers from layout-driven state updates to ref-based measure() calls executed in useLayoutEffect.

Adds refs for both accessory containers, triggers synchronous measurements when accessories change, and keeps onLayout handlers as a fallback to update widths when measure() reports 0 or content updates.

Written by Cursor Bugbot for commit 09b9bc1. This will update automatically on new commits. Configure here.

Replace onLayout callbacks with useLayoutEffect + View.measure() to measure accessory widths synchronously before paint, eliminating visible layout flicker during initial render.

Changes:
- Add useLayoutEffect with View.measure() for synchronous dimension measurement
- Replace onLayout callbacks with refs
- Remove handleStartAccessoryLayout and handleEndAccessoryLayout
- Measurements now occur before browser paint, preventing two-stage layout
@github-actions
Copy link
Copy Markdown
Contributor

CLA Signature Action: All authors have signed the CLA. You may need to manually re-run the blocking PR check if it doesn't pass in a few minutes.

@metamaskbot metamaskbot added the team-design-system All issues relating to design system in Mobile label Feb 10, 2026
Comment thread app/component-library/components/HeaderBase/HeaderBase.tsx Outdated
Comment thread app/component-library/components/HeaderBase/HeaderBase.tsx Outdated
Address Cursor bug report issues:
- Keep both useLayoutEffect + measure() AND onLayout callbacks (High Severity)
- Update dependencies to track actual content, not just boolean existence (Medium Severity)

Changes:
- Add back onLayout callbacks as fallback when measure() returns 0
- Update useLayoutEffect dependencies to include actual accessory props
- Add width comparison checks to prevent unnecessary state updates
- Improve comments to clarify dual measurement strategy

The useLayoutEffect attempts synchronous measurement to reduce flicker,
while onLayout provides fallback for initial mount and content changes.
@github-actions
Copy link
Copy Markdown
Contributor

🔍 Smart E2E Test Selection

  • Selected E2E tags: SmokeAccounts, SmokeConfirmations, SmokeWalletPlatform
  • Selected Performance tags: @PerformanceAccountList
  • Risk Level: medium
  • AI Confidence: 78%
click to see 🤖 AI reasoning details

E2E Test Selection:
The change modifies the HeaderBase component, a widely-used UI component across the app. The modification changes the measurement timing from using only onLayout callbacks to using useLayoutEffect with measure() for synchronous measurement, with onLayout as a fallback. This is a rendering optimization to reduce visual flicker.

HeaderBase is used in:

  1. MultichainAccounts views (account details, wallet details, SRP reveal, account name editing) - affects SmokeAccounts
  2. Confirmations (multiple-alert-modal) - affects SmokeConfirmations
  3. TokensFullView, ActivityView, Navbar - affects SmokeWalletPlatform
  4. QRTabSwitcher, UpdateNeeded, and other header components

The change is relatively contained (measurement timing logic) but affects a core UI component used across many flows. Selected tags cover the main areas where HeaderBase is used to ensure no visual regressions occur.

Performance Test Selection:
This change is explicitly a performance optimization to reduce visual flicker in the HeaderBase component by using useLayoutEffect with measure() for synchronous measurement. HeaderBase is used in account-related views (BaseAccountDetails, WalletDetails, AccountGroupDetails), making @PerformanceAccountList relevant to verify the optimization doesn't negatively impact account list rendering performance. The change could affect header rendering times across the app.

View GitHub Actions results

Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 2 potential issues.

startAccessoryRef.current.measure((_x, _y, width, _height) => {
if (width > 0 && width !== startAccessoryWidth) {
setStartAccessoryWidth(width);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Guard width > 0 prevents resetting stale accessory widths

High Severity

The width > 0 guard in both the measure() callback and the onLayout fallback prevents accessory widths from ever resetting to zero. In Compact variant, when one accessory is removed while the other remains, the wrapper still renders (for centering) but contains no children, resulting in a width-0 layout event. The guard blocks this update, so startAccessoryWidth or endAccessoryWidth retains its stale non-zero value. This causes accessoryWrapperWidth via Math.max to use an incorrect measurement, misaligning the title. The previous code had no such guard and unconditionally called setStartAccessoryWidth(e.nativeEvent.layout.width), correctly handling the zero-width case.

Additional Locations (1)

Fix in Cursor Fix in Web

endButtonIconProps,
startAccessoryWidth,
endAccessoryWidth,
]);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

State values in useLayoutEffect deps cause extra cycles

Medium Severity

Including startAccessoryWidth and endAccessoryWidth (state values) in the useLayoutEffect dependency array causes the effect to re-run every time a measurement updates the state. Each width change triggers another measure() call that finds the value unchanged and does nothing. The reference pattern in useFeedScrollManager.ts deliberately excludes state from its useLayoutEffect deps to avoid this redundant cycle. Removing these two state entries from the array would eliminate the unnecessary re-execution.

Fix in Cursor Fix in Web

@sonarqubecloud
Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
33.3% Coverage on New Code (required ≥ 80%)

See analysis details on SonarQube Cloud

@github-actions github-actions Bot locked and limited conversation to collaborators Apr 21, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

size-S team-design-system All issues relating to design system in Mobile

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants