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]);
+};