This is an automated email from the ASF dual-hosted git repository.

bbovenzi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new af6b5858657 Keep Grid scroll position when opening a task. (#73123)
af6b5858657 is described below

commit af6b58586576915a9524ded84d13ae42583828c4
Author: Software Developer <[email protected]>
AuthorDate: Wed Sep 23 15:27:58 2026 +0200

    Keep Grid scroll position when opening a task. (#73123)
    
    * fix scrolling problem.
    
    * change approach.
    
    * simplify approach.
    
    * simplify approach.
    
    * simplify approach.
    
    * add missing tests. address PR comments.
---
 .../airflow/ui/src/layouts/Details/Grid/Grid.tsx   |   9 +-
 .../Details/Grid/useGridScrollRestore.test.ts      | 201 +++++++++++++++++++++
 .../layouts/Details/Grid/useGridScrollRestore.ts   |  87 +++++++++
 3 files changed, 295 insertions(+), 2 deletions(-)

diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/Grid.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/Grid.tsx
index 06c0ae7803e..58301256bd2 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/Grid.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/Grid.tsx
@@ -43,6 +43,7 @@ import { TaskNames } from "./TaskNames";
 import { GANTT_ROW_OFFSET_PX, GRID_HEADER_HEIGHT_PX, GRID_HEADER_PADDING_PX, 
ROW_HEIGHT } from "./constants";
 import { useGridPagination } from "./useGridPagination";
 import { useGridRunsWithVersionFlags } from "./useGridRunsWithVersionFlags";
+import { useGridScrollRestore } from "./useGridScrollRestore";
 import { estimateTaskNameColumnWidthPx, flattenNodes } from "./utils";
 
 dayjs.extend(dayjsDuration);
@@ -90,7 +91,7 @@ export const Grid = ({
   const usesSharedScroll = Boolean(sharedScrollContainerRef && showGantt);
 
   const { openGroupIds, toggleGroupId } = useGroups();
-  const { dagId = "" } = useParams();
+  const { dagId = "", groupId: selectedGroupId, taskId: selectedTaskId } = 
useParams();
   const [searchParams] = useSearchParams();
 
   const filterRoot = searchParams.get("root") ?? undefined;
@@ -185,6 +186,8 @@ export const Grid = ({
   const handleCellClick = useCallback(() => setMode(NavigationModes.TI), 
[setMode]);
   const handleColumnClick = useCallback(() => setMode(NavigationModes.RUN), 
[setMode]);
 
+  const headerPad = usesSharedScroll ? GANTT_ROW_OFFSET_PX : 
GRID_INNER_SCROLL_PADDING_START_PX;
+
   const rowVirtualizer = useVirtualizer({
     count: flatNodes.length,
     estimateSize: () => ROW_HEIGHT,
@@ -192,9 +195,11 @@ export const Grid = ({
     getScrollElement: () =>
       usesSharedScroll ? (sharedScrollContainerRef?.current ?? null) : 
scrollContainerRef.current,
     overscan: 5,
-    scrollPaddingStart: usesSharedScroll ? GANTT_ROW_OFFSET_PX : 
GRID_INNER_SCROLL_PADDING_START_PX,
+    scrollPaddingStart: headerPad,
   });
 
+  useGridScrollRestore({ dagId, flatNodes, headerPad, rowVirtualizer, 
selectedGroupId, selectedTaskId });
+
   const virtualItems = rowVirtualizer.getVirtualItems();
 
   const gridHeaderAndBody = (
diff --git 
a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/useGridScrollRestore.test.ts
 
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/useGridScrollRestore.test.ts
new file mode 100644
index 00000000000..ab4ee39b5e6
--- /dev/null
+++ 
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/useGridScrollRestore.test.ts
@@ -0,0 +1,201 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import type { Virtualizer } from "@tanstack/react-virtual";
+import { renderHook } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import { ROW_HEIGHT } from "./constants";
+import { useGridScrollRestore } from "./useGridScrollRestore";
+import type { GridTask } from "./utils";
+
+const HEADER_PAD = 50;
+
+/** Fake scroll container exposing only what the hook touches, plus a way to 
fire a scroll event. */
+const makeScrollEl = (clientHeight: number) => {
+  const handlers: Array<() => void> = [];
+
+  return {
+    addEventListener: vi.fn((_type: string, cb: () => void) => 
handlers.push(cb)),
+    clientHeight,
+    dispatchScroll: () => handlers.forEach((cb) => cb()),
+    removeEventListener: vi.fn((_type: string, cb: () => void) => {
+      const index = handlers.indexOf(cb);
+
+      if (index >= 0) {
+        handlers.splice(index, 1);
+      }
+    }),
+    scrollTop: 0,
+  };
+};
+
+const makeVirtualizer = (scrollElement: ReturnType<typeof makeScrollEl> | 
null) => {
+  const scrollToIndex = vi.fn();
+
+  return {
+    rowVirtualizer: { scrollElement, scrollToIndex } as unknown as 
Virtualizer<HTMLDivElement, Element>,
+    scrollToIndex,
+  };
+};
+
+const makeTask = (id: string): GridTask => ({ id }) as unknown as GridTask;
+const makeTasks = (count: number) => Array.from({ length: count }, (_, index) 
=> makeTask(`task_${index}`));
+
+describe("useGridScrollRestore", () => {
+  it("does nothing when there is no scroll element yet", () => {
+    const { rowVirtualizer, scrollToIndex } = makeVirtualizer(null);
+
+    renderHook(() =>
+      useGridScrollRestore({
+        dagId: "dag_no_el",
+        flatNodes: makeTasks(3),
+        headerPad: HEADER_PAD,
+        rowVirtualizer,
+        selectedTaskId: "task_2",
+      }),
+    );
+
+    expect(scrollToIndex).not.toHaveBeenCalled();
+  });
+
+  it("leaves the scroll position untouched on the first visit to a Dag", () => 
{
+    const scrollEl = makeScrollEl(100);
+    const { rowVirtualizer } = makeVirtualizer(scrollEl);
+
+    renderHook(() =>
+      useGridScrollRestore({ dagId: "dag_unseen", flatNodes: [], headerPad: 
HEADER_PAD, rowVirtualizer }),
+    );
+
+    expect(scrollEl.scrollTop).toBe(0);
+  });
+
+  it("saves the position on scroll and restores it after a remount", () => {
+    const first = makeScrollEl(100);
+    const before = makeVirtualizer(first);
+    const { unmount } = renderHook(() =>
+      useGridScrollRestore({
+        dagId: "dag_restore",
+        flatNodes: [],
+        headerPad: HEADER_PAD,
+        rowVirtualizer: before.rowVirtualizer,
+      }),
+    );
+
+    first.scrollTop = 240;
+    first.dispatchScroll();
+    unmount();
+
+    const second = makeScrollEl(100);
+    const after = makeVirtualizer(second);
+
+    renderHook(() =>
+      useGridScrollRestore({
+        dagId: "dag_restore",
+        flatNodes: [],
+        headerPad: HEADER_PAD,
+        rowVirtualizer: after.rowVirtualizer,
+      }),
+    );
+
+    expect(second.scrollTop).toBe(240);
+  });
+
+  it("centers the selected task when the structure changed and it is out of 
view", () => {
+    const scrollEl = makeScrollEl(HEADER_PAD + 2 * ROW_HEIGHT);
+    const { rowVirtualizer, scrollToIndex } = makeVirtualizer(scrollEl);
+
+    renderHook(() =>
+      useGridScrollRestore({
+        dagId: "dag_center",
+        flatNodes: makeTasks(20),
+        headerPad: HEADER_PAD,
+        rowVirtualizer,
+        selectedTaskId: "task_10",
+      }),
+    );
+
+    expect(scrollToIndex).toHaveBeenCalledWith(10, { align: "center" });
+  });
+
+  it("centers a selected group when no task is selected", () => {
+    const scrollEl = makeScrollEl(HEADER_PAD + 2 * ROW_HEIGHT);
+    const flatNodes = [...makeTasks(15), makeTask("group_x")];
+    const { rowVirtualizer, scrollToIndex } = makeVirtualizer(scrollEl);
+
+    renderHook(() =>
+      useGridScrollRestore({
+        dagId: "dag_group",
+        flatNodes,
+        headerPad: HEADER_PAD,
+        rowVirtualizer,
+        selectedGroupId: "group_x",
+      }),
+    );
+
+    expect(scrollToIndex).toHaveBeenCalledWith(15, { align: "center" });
+  });
+
+  it("does not scroll when the selected task is already visible", () => {
+    const scrollEl = makeScrollEl(HEADER_PAD + 4 * ROW_HEIGHT);
+    const { rowVirtualizer, scrollToIndex } = makeVirtualizer(scrollEl);
+
+    renderHook(() =>
+      useGridScrollRestore({
+        dagId: "dag_visible",
+        flatNodes: makeTasks(20),
+        headerPad: HEADER_PAD,
+        rowVirtualizer,
+        selectedTaskId: "task_1",
+      }),
+    );
+
+    expect(scrollToIndex).not.toHaveBeenCalled();
+  });
+
+  it("does nothing when the selected id is not among the rows", () => {
+    const scrollEl = makeScrollEl(HEADER_PAD + 2 * ROW_HEIGHT);
+    const { rowVirtualizer, scrollToIndex } = makeVirtualizer(scrollEl);
+
+    renderHook(() =>
+      useGridScrollRestore({
+        dagId: "dag_missing",
+        flatNodes: makeTasks(20),
+        headerPad: HEADER_PAD,
+        rowVirtualizer,
+        selectedTaskId: "task_missing",
+      }),
+    );
+
+    expect(scrollToIndex).not.toHaveBeenCalled();
+  });
+
+  it("removes the scroll listener on unmount", () => {
+    const scrollEl = makeScrollEl(100);
+    const { rowVirtualizer } = makeVirtualizer(scrollEl);
+    const { unmount } = renderHook(() =>
+      useGridScrollRestore({ dagId: "dag_cleanup", flatNodes: [], headerPad: 
HEADER_PAD, rowVirtualizer }),
+    );
+
+    expect(scrollEl.addEventListener).toHaveBeenCalledWith("scroll", 
expect.any(Function), { passive: true });
+
+    unmount();
+
+    expect(scrollEl.removeEventListener).toHaveBeenCalledWith("scroll", 
expect.any(Function));
+  });
+});
diff --git 
a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/useGridScrollRestore.ts 
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/useGridScrollRestore.ts
new file mode 100644
index 00000000000..00a59eee642
--- /dev/null
+++ 
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/useGridScrollRestore.ts
@@ -0,0 +1,87 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { useLayoutEffect } from "react";
+
+import type { Virtualizer } from "@tanstack/react-virtual";
+
+import { ROW_HEIGHT } from "./constants";
+import type { GridTask } from "./utils";
+
+// Last Grid scrollTop per Dag. Module scope so it outlives the Grid 
remounting.
+const gridScrollTops = new Map<string, number>();
+
+type Params = {
+  dagId: string;
+  flatNodes: Array<GridTask>;
+  // Height of the sticky header above the rows (also the virtualizer's 
scrollPaddingStart).
+  headerPad: number;
+  rowVirtualizer: Virtualizer<HTMLDivElement, Element>;
+  selectedGroupId?: string;
+  selectedTaskId?: string;
+};
+
+// Navigating into a task swaps the route (Dag/Run/TaskInstance each render 
their own Grid), so the
+// Grid unmounts and a fresh one mounts scrolled to the top. We restore the 
last position here so a
+// click doesn't jump the Grid: the rows are unchanged across the remount, so 
the saved pixel offset
+// still points at the same rows.
+//
+// If the rows did change since we saved (a new version, or groups 
expanded/collapsed) the saved
+// offset no longer lines up, so we fall back to locating the selected task by 
id and centering it.
+// Rows are a fixed height, so the index alone gives the offset — no 
dependency on the stored pixels.
+export const useGridScrollRestore = ({
+  dagId,
+  flatNodes,
+  headerPad,
+  rowVirtualizer,
+  selectedGroupId,
+  selectedTaskId,
+}: Params) => {
+  useLayoutEffect(() => {
+    const scrollEl = rowVirtualizer.scrollElement;
+
+    if (!scrollEl) {
+      return undefined;
+    }
+
+    const saved = gridScrollTops.get(dagId);
+
+    if (saved !== undefined) {
+      scrollEl.scrollTop = saved;
+    }
+
+    const anchorId = selectedTaskId ?? selectedGroupId;
+    const index = flatNodes.findIndex((node) => node.id === anchorId);
+
+    if (index !== -1) {
+      const rowTop = headerPad + index * ROW_HEIGHT;
+      const outOfView =
+        rowTop < scrollEl.scrollTop || rowTop + ROW_HEIGHT > 
scrollEl.scrollTop + scrollEl.clientHeight;
+
+      if (outOfView) {
+        rowVirtualizer.scrollToIndex(index, { align: "center" });
+      }
+    }
+
+    const handleScroll = () => gridScrollTops.set(dagId, scrollEl.scrollTop);
+
+    scrollEl.addEventListener("scroll", handleScroll, { passive: true });
+
+    return () => scrollEl.removeEventListener("scroll", handleScroll);
+  }, [dagId, selectedTaskId, selectedGroupId, flatNodes, headerPad, 
rowVirtualizer]);
+};

Reply via email to