Skip to content

v3 - maintainVisibleContentPosition does not compensate web ListHeaderComponent height changes in Chrome #468

Description

@ACHP

Description

On web, changing the height of ListHeaderComponent after the list has already mounted can permanently shift the visible items when maintainVisibleContentPosition is enabled.

This is reproducible in Chrome (not firefox) using this example
https://github.com/ACHP/legend-list/blob/4febe790126b1534babac7f17a07a048b9a1d207/example-web/src/fixtures/HeaderResizeMvcpJumpExample.tsx

See code

import React from "react";

import { LegendList, type LegendListRef } from "@legendapp/list/react";

const DATA = Array.from({ length: 15 }, (_, index) => ({ id: String(index) }));
const ROW_HEIGHT = 70;
const SMALL_HEADER_HEIGHT = 60;
const BIG_HEADER_HEIGHT = 120;
const HEADER_GROW_DELAY_MS = 1000;

function Header({ big }: { big: boolean }) {
    return (
        <div
            style={{
                alignItems: "center",
                background: "tomato",
                boxSizing: "border-box",
                color: "white",
                display: "flex",
                fontWeight: 700,
                height: big ? BIG_HEADER_HEIGHT : SMALL_HEADER_HEIGHT,
                justifyContent: "center",
            }}
        >
            <span>header {big ? "big (120)" : "small (60)"}</span>
        </div>
    );
}

export default function HeaderResizeMvcpJumpExample() {
    const listRef = React.useRef<LegendListRef | null>(null);
    const [bigHeader, setBigHeader] = React.useState(false);
    const [mountKey, setMountKey] = React.useState(0);

    React.useEffect(() => {
        const timer = window.setTimeout(() => {
            setBigHeader(true);
        }, HEADER_GROW_DELAY_MS);

        return () => {
            window.clearTimeout(timer);
        };
    }, [mountKey]);

    React.useEffect(() => {
        let frame = 0;
        let stopped = false;

        const sample = () => {
            if (stopped) {
                return;
            }

            frame = window.requestAnimationFrame(sample);
        };

        frame = window.requestAnimationFrame(sample);

        return () => {
            stopped = true;
            window.cancelAnimationFrame(frame);
        };
    }, []);

    const reset = () => {
        setBigHeader(false);
        setMountKey((value) => value + 1);
    };

    return (
        <div style={{ background: "#d7dde8", flex: 1, minHeight: 0, padding: 16 }}>
            <div style={{ display: "flex", gap: 8, marginBottom: 12 }}>
                <button
                    onClick={reset}
                    style={{ background: "#1f2937", borderRadius: 6, color: "white", padding: "8px 12px" }}
                    type="button"
                >
                    Remount
                </button>
                <button
                    onClick={() => setBigHeader((value) => !value)}
                    style={{ background: "#047857", borderRadius: 6, color: "white", padding: "8px 12px" }}
                    type="button"
                >
                    Toggle header
                </button>
                <div style={{ alignItems: "center", display: "flex", fontFamily: "monospace", fontSize: 13 }}>
                    header: {bigHeader ? "big" : "small"}
                </div>
            </div>

            <div
                style={{
                    background: "white",
                    border: "1px solid #ef4444",
                    boxSizing: "border-box",
                    display: "flex",
                    flexDirection: "column",
                    height: 500,
                    width: 320,
                }}
            >
                <LegendList
                    alignItemsAtEnd
                    className="min-h-0 flex-1"
                    data={DATA}
                    initialScrollIndex={{ index: DATA.length - 1, viewPosition: 0 }}
                    key={mountKey}
                    keyExtractor={(item) => item.id}
                    ListHeaderComponent={<Header big={bigHeader} />}
                    maintainVisibleContentPosition
                    recycleItems
                    ref={listRef}
                    renderItem={({ item }) => (
                        <div
                            style={{
                                alignItems: "center",
                                borderBottom: "1px solid #d1d5db",
                                boxSizing: "border-box",
                                display: "flex",
                                height: ROW_HEIGHT,
                                paddingLeft: 12,
                            }}
                        >
                            row {item.id}
                        </div>
                    )}
                />
            </div>
        </div>
    );
}

Repro flow:

You can find the fork here https://github.com/ACHP/legend-list/tree/header-size-change-bug-chrome
You just have to navigate to the Header Resize MVCP Jump section

What it does:

  1. Render a scrollable LegendList on web.
  2. Use initialScrollIndex to open at the bottom so the header is fully above the viewport.
  3. Enable maintainVisibleContentPosition.
  4. Render a ListHeaderComponent with height 60px.
  5. After mount, change only the header height to 120px.
  6. Keep data unchanged.

Expected:

The visible content should remain anchored. If the list is at the bottom, it should stay at the bottom and distBottom should remain 0.

Observed in Chrome:

The item content shifts by the header delta. distBottom becomes roughly the header delta, for example ~60px, and stays there permanently.

Observed in Firefox:

The bug is not visible because Firefox’s native scroll anchoring appears to compensate the shift automatically

(Left side is Chrome, right side is Firefox (Zen browser))

bugllheader.mp4

Possible Root Cause (AI generated - Codex)

LegendList item positions are stored in positions[] without the header offset. The rendered item top is effectively:

topPad + positions[index]

where:

topPad = stylePaddingTop + headerSize

When headerSize changes, all item DOM positions move because the header is in normal flow above the absolutely positioned item layer. But MVCP anchors only compare positions[index], which does not include headerSize, so the anchor appears unchanged to LegendList and no JS scroll compensation is applied.

Firefox hides this because native browser scroll anchoring compensates the layout shift. Chrome does not reliably do so here, likely because the visible virtualized items are absolutely positioned and/or because recent programmatic scrolls suppress native anchoring. Therefore Chrome exposes the missing JS compensation.

Possible fix (AI generated - Codex):

Subject: [PATCH] fix header size measurement
---
Index: src/components/ListComponent.tsx
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/components/ListComponent.tsx b/src/components/ListComponent.tsx
--- a/src/components/ListComponent.tsx	(revision 4febe790126b1534babac7f17a07a048b9a1d207)
+++ b/src/components/ListComponent.tsx	(revision 81ceca9206a85524deddd33955110580afa27252)
@@ -11,6 +11,7 @@
 import { WebAnchoredEndSpace } from "@/components/WebAnchoredEndSpace";
 import { ENABLE_DEVMODE } from "@/constants";
 import type { ScrollAdjustHandler } from "@/core/ScrollAdjustHandler";
+import { updateHeaderSize } from "@/core/updateHeaderSize";
 import { useStableRenderComponent } from "@/hooks/useStableRenderComponent";
 import { LayoutView } from "@/platform/LayoutView";
 import { Platform } from "@/platform/Platform";
@@ -111,7 +112,7 @@
     useLayoutEffect(() => {
         // Handle header/footer getting toggled on and off, remove header/footer size when they are not present
         if (!ListHeaderComponent) {
-            set$(ctx, "headerSize", 0);
+            updateHeaderSize(ctx, 0);
         }
         if (!ListFooterComponent) {
             set$(ctx, "footerSize", 0);
@@ -121,7 +122,7 @@
     const onLayoutHeader = useCallback(
         (rect: LayoutRectangle) => {
             const size = rect[horizontal ? "width" : "height"];
-            set$(ctx, "headerSize", size);
+            updateHeaderSize(ctx, size);
         },
         [ctx, horizontal],
     );
Index: src/core/updateHeaderSize.ts
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/core/updateHeaderSize.ts b/src/core/updateHeaderSize.ts
new file mode 100644
--- /dev/null	(revision 81ceca9206a85524deddd33955110580afa27252)
+++ b/src/core/updateHeaderSize.ts	(revision 81ceca9206a85524deddd33955110580afa27252)
@@ -0,0 +1,69 @@
+import { Platform } from "@/platform/Platform";
+import { peek$, type StateContext, set$ } from "@/state/state";
+import { requestAdjust } from "@/utils/requestAdjust";
+
+const HEADER_MVCP_EPSILON = 0.1;
+const HEADER_NATIVE_SCROLL_EPSILON = 1;
+
+function sameDirection(a: number, b: number) {
+    return (a > 0 && b > 0) || (a < 0 && b < 0);
+}
+
+function getLiveScrollOffset(ctx: StateContext) {
+    try {
+        return ctx.state.refScroller.current?.getCurrentScrollOffset?.();
+    } catch {
+        return undefined;
+    }
+}
+
+function getNativeConsumedDelta(ctx: StateContext, headerDiff: number, prevScroll: number, now: number) {
+    const state = ctx.state;
+    const liveScroll = getLiveScrollOffset(ctx);
+    if (liveScroll !== undefined) {
+        const liveDelta = liveScroll - prevScroll;
+        if (Math.abs(liveDelta) > HEADER_MVCP_EPSILON && sameDirection(liveDelta, headerDiff)) {
+            return liveDelta;
+        }
+    }
+
+    const stateDelta = state.scroll - state.scrollPrev;
+    const didRecentlyObserveMatchingScroll =
+        now - state.scrollTime <= 100 && Math.abs(stateDelta - headerDiff) <= HEADER_NATIVE_SCROLL_EPSILON;
+    return didRecentlyObserveMatchingScroll ? stateDelta : 0;
+}
+
+export function updateHeaderSize(ctx: StateContext, nextHeaderSize: number) {
+    const state = ctx.state;
+    const prevHeaderSize = peek$(ctx, "headerSize") || 0;
+    const hadMeasuredHeader = !!state.didMeasureHeader;
+    const headerDiff = nextHeaderSize - prevHeaderSize;
+
+    set$(ctx, "headerSize", nextHeaderSize);
+    state.didMeasureHeader = true;
+
+    if (
+        !hadMeasuredHeader ||
+        Platform.OS !== "web" ||
+        !state.props.maintainVisibleContentPosition.size ||
+        !state.didFinishInitialScroll ||
+        !state.didContainersLayout ||
+        state.scrollingTo ||
+        Math.abs(headerDiff) <= HEADER_MVCP_EPSILON
+    ) {
+        return;
+    }
+
+    const previousTopOffset = (peek$(ctx, "stylePaddingTop") || 0) + prevHeaderSize;
+    if (state.scroll < previousTopOffset - HEADER_MVCP_EPSILON) {
+        return;
+    }
+
+    const prevScroll = state.scroll;
+    const consumedDelta = getNativeConsumedDelta(ctx, headerDiff, prevScroll, Date.now());
+    const remainingDelta = headerDiff - consumedDelta;
+
+    if (Math.abs(remainingDelta) > HEADER_MVCP_EPSILON) {
+        requestAdjust(ctx, remainingDelta);
+    }
+}
Index: src/types.internal.ts
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/types.internal.ts b/src/types.internal.ts
--- a/src/types.internal.ts	(revision 4febe790126b1534babac7f17a07a048b9a1d207)
+++ b/src/types.internal.ts	(revision 81ceca9206a85524deddd33955110580afa27252)
@@ -146,6 +146,7 @@
     didColumnsChange?: boolean;
     didDataChange?: boolean;
     didFinishInitialScroll?: boolean;
+    didMeasureHeader?: boolean;
     didContainersLayout?: boolean;
     enableScrollForNextCalculateItemsInView: boolean;
     endBuffered: number;

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions