Skip to content
Closed
Changes from all commits
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
62 changes: 52 additions & 10 deletions app/component-library/components/HeaderBase/HeaderBase.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// Third party dependencies.
import React, { useCallback, useState } from 'react';
import React, { useRef, useState, useLayoutEffect, useCallback } from 'react';
import { View, LayoutChangeEvent } from 'react-native';

// External dependencies.
Expand Down Expand Up @@ -42,17 +42,11 @@ const HeaderBase: React.FC<HeaderBaseProps> = ({
const tw = useTailwind();
const insets = useSafeAreaInsets();

const startAccessoryRef = useRef<View>(null);
const endAccessoryRef = useRef<View>(null);
const [startAccessoryWidth, setStartAccessoryWidth] = useState(0);
const [endAccessoryWidth, setEndAccessoryWidth] = useState(0);

const handleStartAccessoryLayout = useCallback((e: LayoutChangeEvent) => {
setStartAccessoryWidth(e.nativeEvent.layout.width);
}, []);

const handleEndAccessoryLayout = useCallback((e: LayoutChangeEvent) => {
setEndAccessoryWidth(e.nativeEvent.layout.width);
}, []);

// Determine alignment and text variant based on variant prop
const isLeftAligned = variant === HeaderBaseVariant.Display;
const textVariant = HEADERBASE_VARIANT_TEXT_VARIANTS[variant];
Expand All @@ -63,6 +57,53 @@ const HeaderBase: React.FC<HeaderBaseProps> = ({
endAccessory || (endButtonIconProps && endButtonIconProps.length > 0);
const hasAnyAccessory = hasStartContent || hasEndContent;

// Attempt synchronous measurement on mount/updates to reduce flicker
useLayoutEffect(() => {
if (startAccessoryRef.current) {
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

});
}

if (endAccessoryRef.current) {
endAccessoryRef.current.measure((_x, _y, width, _height) => {
if (width > 0 && width !== endAccessoryWidth) {
setEndAccessoryWidth(width);
}
});
}
}, [
startAccessory,
startButtonIconProps,
endAccessory,
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


// Fallback onLayout callbacks for when measure() returns 0 or content changes
const handleStartAccessoryLayout = useCallback(
(event: LayoutChangeEvent) => {
const { width } = event.nativeEvent.layout;
if (width > 0 && width !== startAccessoryWidth) {
setStartAccessoryWidth(width);
}
},
[startAccessoryWidth],
);

const handleEndAccessoryLayout = useCallback(
(event: LayoutChangeEvent) => {
const { width } = event.nativeEvent.layout;
if (width > 0 && width !== endAccessoryWidth) {
setEndAccessoryWidth(width);
}
},
[endAccessoryWidth],
);

// For Compact: render both wrappers if any accessory exists (for centering)
// For Display: only render wrappers if their respective accessory exists
const shouldRenderStartWrapper = isLeftAligned
Expand Down Expand Up @@ -150,7 +191,7 @@ const HeaderBase: React.FC<HeaderBaseProps> = ({
}
{...startAccessoryWrapperProps}
>
<View onLayout={handleStartAccessoryLayout}>
<View ref={startAccessoryRef} onLayout={handleStartAccessoryLayout}>
{renderStartContent()}
</View>
</View>
Expand Down Expand Up @@ -182,6 +223,7 @@ const HeaderBase: React.FC<HeaderBaseProps> = ({
{...endAccessoryWrapperProps}
>
<View
ref={endAccessoryRef}
onLayout={handleEndAccessoryLayout}
style={
hasMultipleEndButtons ? tw.style('flex-row gap-2') : undefined
Expand Down
Loading