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

bbovenzi pushed a commit to branch v3-1-test
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/v3-1-test by this push:
     new 642a0d64747 [v3-1-test] Add virtualization to grid view (#60241) 
(#60285)
642a0d64747 is described below

commit 642a0d6474792c217f261996827c16bb9a3d8f26
Author: github-actions[bot] 
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Thu Jan 8 15:29:00 2026 -0500

    [v3-1-test] Add virtualization to grid view (#60241) (#60285)
    
    * Add virtualization to grid view
    
    * Remove all jsx inline function declarations
    
    * Remove awkward Bar code reuse, add nav mode const
    
    * Remove extraneous code
    
    * Update airflow-core/src/airflow/ui/src/layouts/Details/Grid/TaskNames.tsx
    
    
    
    * Fix scrollbars and horizontal scrolling
    
    ---------
    (cherry picked from commit 7a64304948ac982950db4a6ecc10e0f90cbd4ede)
    
    Co-authored-by: Brent Bovenzi <[email protected]>
    Co-authored-by: Pierre Jeambrun <[email protected]>
---
 .../src/airflow/ui/src/context/hover/Context.ts    |   2 +
 .../airflow/ui/src/context/hover/HoverProvider.tsx |   5 +-
 .../src/airflow/ui/src/hooks/navigation/index.ts   |   2 +
 .../src/airflow/ui/src/hooks/navigation/types.ts   |   8 +-
 .../ui/src/hooks/navigation/useNavigation.ts       |  35 +--
 .../ui/src/layouts/Details/DetailsLayout.tsx       |   2 +-
 .../airflow/ui/src/layouts/Details/Grid/Bar.tsx    |  31 ++-
 .../airflow/ui/src/layouts/Details/Grid/Grid.tsx   | 123 ++++++----
 .../airflow/ui/src/layouts/Details/Grid/GridTI.tsx |  48 ++--
 .../layouts/Details/Grid/TaskInstancesColumn.tsx   | 111 +++++++--
 .../ui/src/layouts/Details/Grid/TaskNames.tsx      | 249 ++++++++++++---------
 .../ui/src/layouts/Details/PanelButtons.tsx        |   6 +-
 12 files changed, 374 insertions(+), 248 deletions(-)

diff --git a/airflow-core/src/airflow/ui/src/context/hover/Context.ts 
b/airflow-core/src/airflow/ui/src/context/hover/Context.ts
index e834b371702..d7c35c7e401 100644
--- a/airflow-core/src/airflow/ui/src/context/hover/Context.ts
+++ b/airflow-core/src/airflow/ui/src/context/hover/Context.ts
@@ -19,7 +19,9 @@
 import { createContext } from "react";
 
 export type HoverContextType = {
+  hoveredRunId: string | undefined;
   hoveredTaskId: string | undefined;
+  setHoveredRunId: (runId: string | undefined) => void;
   setHoveredTaskId: (taskId: string | undefined) => void;
 };
 
diff --git a/airflow-core/src/airflow/ui/src/context/hover/HoverProvider.tsx 
b/airflow-core/src/airflow/ui/src/context/hover/HoverProvider.tsx
index e125ce28965..5bff0e92587 100644
--- a/airflow-core/src/airflow/ui/src/context/hover/HoverProvider.tsx
+++ b/airflow-core/src/airflow/ui/src/context/hover/HoverProvider.tsx
@@ -22,14 +22,17 @@ import { useState, useMemo } from "react";
 import { HoverContext } from "./Context";
 
 export const HoverProvider = ({ children }: PropsWithChildren) => {
+  const [hoveredRunId, setHoveredRunId] = useState<string | 
undefined>(undefined);
   const [hoveredTaskId, setHoveredTaskId] = useState<string | 
undefined>(undefined);
 
   const value = useMemo(
     () => ({
+      hoveredRunId,
       hoveredTaskId,
+      setHoveredRunId,
       setHoveredTaskId,
     }),
-    [hoveredTaskId],
+    [hoveredRunId, hoveredTaskId],
   );
 
   return <HoverContext.Provider 
value={value}>{children}</HoverContext.Provider>;
diff --git a/airflow-core/src/airflow/ui/src/hooks/navigation/index.ts 
b/airflow-core/src/airflow/ui/src/hooks/navigation/index.ts
index 0f9e9f69a23..05a81aed314 100644
--- a/airflow-core/src/airflow/ui/src/hooks/navigation/index.ts
+++ b/airflow-core/src/airflow/ui/src/hooks/navigation/index.ts
@@ -20,6 +20,8 @@
 export { useNavigation } from "./useNavigation";
 export { useKeyboardNavigation } from "./useKeyboardNavigation";
 
+export { NavigationModes } from "./types";
+
 export type {
   ArrowKey,
   NavigationDirection,
diff --git a/airflow-core/src/airflow/ui/src/hooks/navigation/types.ts 
b/airflow-core/src/airflow/ui/src/hooks/navigation/types.ts
index a3a7a1c9a31..af5a7eac8ae 100644
--- a/airflow-core/src/airflow/ui/src/hooks/navigation/types.ts
+++ b/airflow-core/src/airflow/ui/src/hooks/navigation/types.ts
@@ -19,7 +19,13 @@
 import type { GridRunsResponse } from "openapi/requests";
 import type { GridTask } from "src/layouts/Details/Grid/utils";
 
-export type NavigationMode = "run" | "task" | "TI";
+export const NavigationModes = {
+  RUN: "run",
+  TASK: "task",
+  TI: "TI",
+} as const;
+
+export type NavigationMode = (typeof NavigationModes)[keyof typeof 
NavigationModes];
 
 export type ArrowKey = "ArrowDown" | "ArrowLeft" | "ArrowRight" | "ArrowUp";
 
diff --git a/airflow-core/src/airflow/ui/src/hooks/navigation/useNavigation.ts 
b/airflow-core/src/airflow/ui/src/hooks/navigation/useNavigation.ts
index 0c831ecb309..cab377b313d 100644
--- a/airflow-core/src/airflow/ui/src/hooks/navigation/useNavigation.ts
+++ b/airflow-core/src/airflow/ui/src/hooks/navigation/useNavigation.ts
@@ -23,36 +23,37 @@ import type { GridRunsResponse } from "openapi/requests";
 import type { GridTask } from "src/layouts/Details/Grid/utils";
 import { buildTaskInstanceUrl } from "src/utils/links";
 
-import type {
-  NavigationDirection,
-  NavigationIndices,
-  NavigationMode,
-  UseNavigationProps,
-  UseNavigationReturn,
+import {
+  NavigationModes,
+  type NavigationDirection,
+  type NavigationIndices,
+  type NavigationMode,
+  type UseNavigationProps,
+  type UseNavigationReturn,
 } from "./types";
 import { useKeyboardNavigation } from "./useKeyboardNavigation";
 
 const detectModeFromUrl = (pathname: string): NavigationMode => {
   if (pathname.includes("/runs/") && pathname.includes("/tasks/")) {
-    return "TI";
+    return NavigationModes.TI;
   }
   if (pathname.includes("/runs/") && !pathname.includes("/tasks/")) {
-    return "run";
+    return NavigationModes.RUN;
   }
   if (pathname.includes("/tasks/") && !pathname.includes("/runs/")) {
-    return "task";
+    return NavigationModes.TASK;
   }
 
-  return "TI";
+  return NavigationModes.TI;
 };
 
 const isValidDirection = (direction: NavigationDirection, mode: 
NavigationMode): boolean => {
   switch (mode) {
-    case "run":
+    case NavigationModes.RUN:
       return direction === "left" || direction === "right";
-    case "task":
+    case NavigationModes.TASK:
       return direction === "down" || direction === "up";
-    case "TI":
+    case NavigationModes.TI:
       return true;
     default:
       return false;
@@ -74,11 +75,11 @@ const buildPath = (params: {
   const groupPath = task.isGroup ? "group/" : "";
 
   switch (mode) {
-    case "run":
+    case NavigationModes.RUN:
       return `/dags/${dagId}/runs/${run.run_id}`;
-    case "task":
+    case NavigationModes.TASK:
       return `/dags/${dagId}/tasks/${groupPath}${task.id}`;
-    case "TI":
+    case NavigationModes.TI:
       return buildTaskInstanceUrl({
         currentPathname: pathname,
         dagId,
@@ -98,7 +99,7 @@ export const useNavigation = ({ onToggleGroup, runs, tasks }: 
UseNavigationProps
   const enabled = Boolean(dagId) && (Boolean(runId) || Boolean(taskId) || 
Boolean(groupId));
   const navigate = useNavigate();
   const location = useLocation();
-  const [mode, setMode] = useState<NavigationMode>("TI");
+  const [mode, setMode] = useState<NavigationMode>(NavigationModes.TI);
 
   useEffect(() => {
     const detectedMode = detectModeFromUrl(globalThis.location.pathname);
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/DetailsLayout.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Details/DetailsLayout.tsx
index 019e7cb5a54..9fbc27bb421 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/DetailsLayout.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/DetailsLayout.tsx
@@ -129,7 +129,7 @@ export const DetailsLayout = ({ children, error, isLoading, 
tabs }: Props) => {
               minSize={showGantt && dagView === "grid" && Boolean(runId) ? 35 
: 6}
               order={1}
             >
-              <Box height="100%" marginInlineEnd={2} overflowY="auto" 
paddingRight={4} position="relative">
+              <Box height="100%" position="relative">
                 <PanelButtons
                   dagView={dagView}
                   limit={limit}
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/Bar.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/Bar.tsx
index 9360ace2256..946acc37387 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/Bar.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/Bar.tsx
@@ -17,39 +17,40 @@
  * under the License.
  */
 import { Flex, Box } from "@chakra-ui/react";
+import { useCallback } from "react";
 import { useParams, useSearchParams } from "react-router-dom";
 
 import type { GridRunsResponse } from "openapi/requests";
 import { RunTypeIcon } from "src/components/RunTypeIcon";
-import { useGridTiSummaries } from "src/queries/useGridTISummaries.ts";
+import { useHover } from "src/context/hover";
 
 import { GridButton } from "./GridButton";
-import { TaskInstancesColumn } from "./TaskInstancesColumn";
-import type { GridTask } from "./utils";
 
 const BAR_HEIGHT = 100;
 
 type Props = {
   readonly max: number;
-  readonly nodes: Array<GridTask>;
-  readonly onCellClick?: () => void;
-  readonly onColumnClick?: () => void;
+  readonly onClick?: () => void;
   readonly run: GridRunsResponse;
 };
 
-export const Bar = ({ max, nodes, onCellClick, onColumnClick, run }: Props) => 
{
+export const Bar = ({ max, onClick, run }: Props) => {
   const { dagId = "", runId } = useParams();
   const [searchParams] = useSearchParams();
+  const { hoveredRunId, setHoveredRunId } = useHover();
 
   const isSelected = runId === run.run_id;
-
+  const isHovered = hoveredRunId === run.run_id;
   const search = searchParams.toString();
-  const { data: gridTISummaries } = useGridTiSummaries({ dagId, runId: 
run.run_id, state: run.state });
+
+  const handleMouseEnter = useCallback(() => setHoveredRunId(run.run_id), 
[setHoveredRunId, run.run_id]);
+  const handleMouseLeave = useCallback(() => setHoveredRunId(undefined), 
[setHoveredRunId]);
 
   return (
     <Box
-      _hover={{ bg: "brand.subtle" }}
-      bg={isSelected ? "brand.muted" : undefined}
+      bg={isSelected ? "brand.emphasized" : isHovered ? "brand.muted" : 
undefined}
+      onMouseEnter={handleMouseEnter}
+      onMouseLeave={handleMouseLeave}
       position="relative"
       transition="background-color 0.2s"
     >
@@ -57,7 +58,7 @@ export const Bar = ({ max, nodes, onCellClick, onColumnClick, 
run }: Props) => {
         alignItems="flex-end"
         height={BAR_HEIGHT}
         justifyContent="center"
-        onClick={onColumnClick}
+        onClick={onClick}
         pb="2px"
         px="5px"
         width="18px"
@@ -80,12 +81,6 @@ export const Bar = ({ max, nodes, onCellClick, 
onColumnClick, run }: Props) => {
           {run.run_type !== "scheduled" && <RunTypeIcon color="white" 
runType={run.run_type} size="10px" />}
         </GridButton>
       </Flex>
-      <TaskInstancesColumn
-        nodes={nodes}
-        onCellClick={onCellClick}
-        runId={run.run_id}
-        taskInstances={gridTISummaries?.task_instances ?? []}
-      />
     </Box>
   );
 };
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 c61a41a960a..eab0d4f3238 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
@@ -17,16 +17,17 @@
  * under the License.
  */
 import { Box, Flex, IconButton } from "@chakra-ui/react";
+import { useVirtualizer } from "@tanstack/react-virtual";
 import dayjs from "dayjs";
 import dayjsDuration from "dayjs/plugin/duration";
-import { useEffect, useMemo, useRef, useState } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
 import { useTranslation } from "react-i18next";
 import { FiChevronsRight } from "react-icons/fi";
 import { Link, useParams } from "react-router-dom";
 
 import type { DagRunType, GridRunsResponse } from "openapi/requests";
 import { useOpenGroups } from "src/context/openGroups";
-import { useNavigation } from "src/hooks/navigation";
+import { NavigationModes, useNavigation } from "src/hooks/navigation";
 import { useGridRuns } from "src/queries/useGridRuns.ts";
 import { useGridStructure } from "src/queries/useGridStructure.ts";
 import { isStatePending } from "src/utils";
@@ -34,11 +35,14 @@ import { isStatePending } from "src/utils";
 import { Bar } from "./Bar";
 import { DurationAxis } from "./DurationAxis";
 import { DurationTick } from "./DurationTick";
+import { TaskInstancesColumn } from "./TaskInstancesColumn";
 import { TaskNames } from "./TaskNames";
 import { flattenNodes } from "./utils";
 
 dayjs.extend(dayjsDuration);
 
+const ROW_HEIGHT = 20;
+
 type Props = {
   readonly limit: number;
   readonly runType?: DagRunType | undefined;
@@ -49,6 +53,7 @@ type Props = {
 export const Grid = ({ limit, runType, showGantt, triggeringUser }: Props) => {
   const { t: translate } = useTranslation("dag");
   const gridRef = useRef<HTMLDivElement>(null);
+  const scrollContainerRef = useRef<HTMLDivElement>(null);
 
   const [selectedIsVisible, setSelectedIsVisible] = useState<boolean | 
undefined>();
   const { openGroupIds, toggleGroupId } = useOpenGroups();
@@ -93,61 +98,97 @@ export const Grid = ({ limit, runType, showGantt, 
triggeringUser }: Props) => {
     tasks: flatNodes,
   });
 
+  const handleRowClick = useCallback(() => setMode(NavigationModes.TASK), 
[setMode]);
+  const handleCellClick = useCallback(() => setMode(NavigationModes.TI), 
[setMode]);
+  const handleColumnClick = useCallback(() => setMode(NavigationModes.RUN), 
[setMode]);
+
+  const rowVirtualizer = useVirtualizer({
+    count: flatNodes.length,
+    estimateSize: () => ROW_HEIGHT,
+    getScrollElement: () => scrollContainerRef.current,
+    overscan: 5,
+  });
+
+  const virtualItems = rowVirtualizer.getVirtualItems();
+
   return (
     <Flex
+      flexDirection="column"
       justifyContent="flex-start"
-      outline="none"
       position="relative"
-      pt={20}
+      pt={16}
       ref={gridRef}
       tabIndex={0}
       width={showGantt ? "1/2" : "full"}
     >
-      <Box display="flex" flexDirection="column" flexGrow={1} 
justifyContent="end" minWidth="200px">
-        <TaskNames nodes={flatNodes} onRowClick={() => setMode("task")} />
-      </Box>
-      <Box position="relative">
-        <Flex position="relative">
-          <DurationAxis top="100px" />
-          <DurationAxis top="50px" />
-          <DurationAxis top="4px" />
-          <Flex flexDirection="column-reverse" height="100px" 
position="relative">
-            {Boolean(gridRuns?.length) && (
-              <>
-                <DurationTick bottom="92px" duration={max} />
-                <DurationTick bottom="46px" duration={max / 2} />
-                <DurationTick bottom="-4px" duration={0} />
-              </>
-            )}
+      {/* Grid scroll container */}
+      <Box
+        height="calc(100vh - 140px)"
+        marginRight={1}
+        overflow="auto"
+        paddingRight={4}
+        position="relative"
+        ref={scrollContainerRef}
+      >
+        {/* Grid header, both bgs are needed to hide elements during 
horizontal and vertical scroll */}
+        <Flex bg="bg" display="flex" position="sticky" pt={2} top={0} 
zIndex={2}>
+          <Box bg="bg" flexGrow={1} left={0} minWidth="200px" 
position="sticky" zIndex={1}>
+            <Flex flexDirection="column-reverse" height="100px" 
position="relative">
+              {Boolean(gridRuns?.length) && (
+                <>
+                  <DurationTick bottom="92px" duration={max} />
+                  <DurationTick bottom="46px" duration={max / 2} />
+                </>
+              )}
+            </Flex>
+          </Box>
+          {/* Duration bars */}
+          <Flex flexDirection="row-reverse" flexShrink={0}>
+            <Flex flexShrink={0} position="relative">
+              <DurationAxis top="100px" />
+              <DurationAxis top="50px" />
+              <DurationAxis top="4px" />
+              <Flex flexDirection="row-reverse">
+                {gridRuns?.map((dr: GridRunsResponse) => (
+                  <Bar key={dr.run_id} max={max} onClick={handleColumnClick} 
run={dr} />
+                ))}
+              </Flex>
+              {selectedIsVisible === undefined || !selectedIsVisible ? 
undefined : (
+                <Link to={`/dags/${dagId}`}>
+                  <IconButton
+                    aria-label={translate("grid.buttons.resetToLatest")}
+                    height="98px"
+                    loading={isLoading}
+                    minW={0}
+                    ml={1}
+                    title={translate("grid.buttons.resetToLatest")}
+                    variant="surface"
+                    zIndex={1}
+                  >
+                    <FiChevronsRight />
+                  </IconButton>
+                </Link>
+              )}
+            </Flex>
           </Flex>
-          <Flex flexDirection="row-reverse">
+        </Flex>
+
+        {/* Grid body */}
+        <Flex height={`${rowVirtualizer.getTotalSize()}px`} 
position="relative">
+          <Box bg="bg" flexGrow={1} flexShrink={0} left={0} minWidth="200px" 
position="sticky" zIndex={1}>
+            <TaskNames nodes={flatNodes} onRowClick={handleRowClick} 
virtualItems={virtualItems} />
+          </Box>
+          <Flex flexDirection="row-reverse" flexShrink={0}>
             {gridRuns?.map((dr: GridRunsResponse) => (
-              <Bar
+              <TaskInstancesColumn
                 key={dr.run_id}
-                max={max}
                 nodes={flatNodes}
-                onCellClick={() => setMode("TI")}
-                onColumnClick={() => setMode("run")}
+                onCellClick={handleCellClick}
                 run={dr}
+                virtualItems={virtualItems}
               />
             ))}
           </Flex>
-          {selectedIsVisible === undefined || !selectedIsVisible ? undefined : 
(
-            <Link to={`/dags/${dagId}`}>
-              <IconButton
-                aria-label={translate("grid.buttons.resetToLatest")}
-                height="98px"
-                loading={isLoading}
-                minW={0}
-                ml={1}
-                title={translate("grid.buttons.resetToLatest")}
-                variant="surface"
-                zIndex={1}
-              >
-                <FiChevronsRight />
-              </IconButton>
-            </Link>
-          )}
         </Flex>
       </Box>
     </Flex>
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridTI.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridTI.tsx
index d4d775da977..cea28632947 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridTI.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridTI.tsx
@@ -17,8 +17,7 @@
  * under the License.
  */
 import { Badge, Flex } from "@chakra-ui/react";
-import type { MouseEvent } from "react";
-import React, { useCallback } from "react";
+import React, { useCallback, useMemo } from "react";
 import { useTranslation } from "react-i18next";
 import { Link, useLocation, useParams, useSearchParams } from 
"react-router-dom";
 
@@ -26,30 +25,9 @@ import type { LightGridTaskInstanceSummary } from 
"openapi/requests/types.gen";
 import { BasicTooltip } from "src/components/BasicTooltip";
 import { StateIcon } from "src/components/StateIcon";
 import Time from "src/components/Time";
-import { type HoverContextType, useHover } from "src/context/hover";
+import { useHover } from "src/context/hover";
 import { buildTaskInstanceUrl } from "src/utils/links";
 
-const handleMouseEnter =
-  (setHoveredTaskId: HoverContextType["setHoveredTaskId"]) => (event: 
MouseEvent<HTMLDivElement>) => {
-    const tasks = 
document.querySelectorAll<HTMLDivElement>(`#${event.currentTarget.id}`);
-
-    tasks.forEach((task) => {
-      task.style.backgroundColor = "var(--chakra-colors-info-subtle)";
-    });
-
-    setHoveredTaskId(event.currentTarget.id.replaceAll("-", "."));
-  };
-
-const handleMouseLeave = (taskId: string, setHoveredTaskId: 
HoverContextType["setHoveredTaskId"]) => () => {
-  const tasks = 
document.querySelectorAll<HTMLDivElement>(`#task-${taskId.replaceAll(".", 
"-")}`);
-
-  tasks.forEach((task) => {
-    task.style.backgroundColor = "";
-  });
-
-  setHoveredTaskId(undefined);
-};
-
 type Props = {
   readonly dagId: string;
   readonly instance: LightGridTaskInstanceSummary;
@@ -62,17 +40,14 @@ type Props = {
 };
 
 const Instance = ({ dagId, instance, isGroup, isMapped, onClick, runId, taskId 
}: Props) => {
-  const { setHoveredTaskId } = useHover();
+  const { hoveredTaskId, setHoveredTaskId } = useHover();
   const { groupId: selectedGroupId, taskId: selectedTaskId } = useParams();
   const { t: translate } = useTranslation();
   const location = useLocation();
 
   const [searchParams] = useSearchParams();
 
-  const onMouseEnter = handleMouseEnter(setHoveredTaskId);
-  const onMouseLeave = handleMouseLeave(taskId, setHoveredTaskId);
-
-  const getTaskUrl = useCallback(
+  const taskUrl = useMemo(
     () =>
       buildTaskInstanceUrl({
         currentPathname: location.pathname,
@@ -85,22 +60,29 @@ const Instance = ({ dagId, instance, isGroup, isMapped, 
onClick, runId, taskId }
     [dagId, isGroup, isMapped, location.pathname, runId, taskId],
   );
 
+  const handleMouseEnter = useCallback(() => setHoveredTaskId(taskId), 
[setHoveredTaskId, taskId]);
+  const handleMouseLeave = useCallback(() => setHoveredTaskId(undefined), 
[setHoveredTaskId]);
+
   // Remove try_number query param when navigating to reset to the
   // latest try of the task instance and avoid issues with invalid try numbers:
   // https://github.com/apache/airflow/issues/56977
   searchParams.delete("try_number");
   const redirectionSearch = searchParams.toString();
 
+  // Determine background: selected takes priority over hovered
+  const isSelected = selectedTaskId === taskId || selectedGroupId === taskId;
+  const isHovered = hoveredTaskId === taskId;
+
   return (
     <Flex
       alignItems="center"
-      bg={selectedTaskId === taskId || selectedGroupId === taskId ? 
"info.muted" : undefined}
+      bg={isSelected ? "brand.emphasized" : isHovered ? "brand.muted" : 
undefined}
       height="20px"
       id={`task-${taskId.replaceAll(".", "-")}`}
       justifyContent="center"
       key={taskId}
-      onMouseEnter={onMouseEnter}
-      onMouseLeave={onMouseLeave}
+      onMouseEnter={handleMouseEnter}
+      onMouseLeave={handleMouseLeave}
       position="relative"
       px="2px"
       py={0}
@@ -135,7 +117,7 @@ const Instance = ({ dagId, instance, isGroup, isMapped, 
onClick, runId, taskId }
           onClick={onClick}
           replace
           to={{
-            pathname: getTaskUrl(),
+            pathname: taskUrl,
             search: redirectionSearch,
           }}
         >
diff --git 
a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/TaskInstancesColumn.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/TaskInstancesColumn.tsx
index c312af33dab..399fbca583a 100644
--- 
a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/TaskInstancesColumn.tsx
+++ 
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/TaskInstancesColumn.tsx
@@ -17,44 +17,107 @@
  * under the License.
  */
 import { Box } from "@chakra-ui/react";
+import type { VirtualItem } from "@tanstack/react-virtual";
+import { memo, useCallback, useMemo } from "react";
 import { useParams } from "react-router-dom";
 
+import type { GridRunsResponse } from "openapi/requests";
 import type { LightGridTaskInstanceSummary } from "openapi/requests/types.gen";
+import { useHover } from "src/context/hover";
+import { useGridTiSummaries } from "src/queries/useGridTISummaries.ts";
 
 import { GridTI } from "./GridTI";
 import type { GridTask } from "./utils";
 
 type Props = {
-  readonly depth?: number;
   readonly nodes: Array<GridTask>;
   readonly onCellClick?: () => void;
-  readonly runId: string;
-  readonly taskInstances: Array<LightGridTaskInstanceSummary>;
+  readonly run: GridRunsResponse;
+  readonly virtualItems?: Array<VirtualItem>;
 };
 
-export const TaskInstancesColumn = ({ nodes, onCellClick, runId, taskInstances 
}: Props) => {
-  const { dagId = "" } = useParams();
+const ROW_HEIGHT = 20;
 
-  return nodes.map((node) => {
-    // todo: how does this work with mapped? same task id for multiple tis
-    const taskInstance = taskInstances.find((ti) => ti.task_id === node.id);
+const TaskInstancesColumnInner = ({ nodes, onCellClick, run, virtualItems }: 
Props) => {
+  const { dagId = "", runId } = useParams();
+  const { data: gridTISummaries } = useGridTiSummaries({ dagId, runId: 
run.run_id, state: run.state });
+  const { hoveredRunId, setHoveredRunId } = useHover();
 
-    if (!taskInstance) {
-      return <Box height="20px" key={`${node.id}-${runId}`} width="18px" />;
+  const itemsToRender =
+    virtualItems ?? nodes.map((_, index) => ({ index, size: ROW_HEIGHT, start: 
index * ROW_HEIGHT }));
+
+  const taskInstanceMap = useMemo(() => {
+    const taskInstances = gridTISummaries?.task_instances ?? [];
+    const map = new Map<string, LightGridTaskInstanceSummary>();
+
+    for (const ti of taskInstances) {
+      map.set(ti.task_id, ti);
     }
 
-    return (
-      <GridTI
-        dagId={dagId}
-        instance={taskInstance}
-        isGroup={node.isGroup}
-        isMapped={node.is_mapped}
-        key={node.id}
-        label={node.label}
-        onClick={onCellClick}
-        runId={runId}
-        taskId={node.id}
-      />
-    );
-  });
+    return map;
+  }, [gridTISummaries?.task_instances]);
+
+  const isSelected = runId === run.run_id;
+  const isHovered = hoveredRunId === run.run_id;
+
+  const handleMouseEnter = useCallback(() => setHoveredRunId(run.run_id), 
[setHoveredRunId, run.run_id]);
+  const handleMouseLeave = useCallback(() => setHoveredRunId(undefined), 
[setHoveredRunId]);
+
+  return (
+    <Box
+      bg={isSelected ? "brand.emphasized" : isHovered ? "brand.muted" : 
undefined}
+      onMouseEnter={handleMouseEnter}
+      onMouseLeave={handleMouseLeave}
+      position="relative"
+      transition="background-color 0.2s"
+      width="18px"
+    >
+      {itemsToRender.map((virtualItem) => {
+        const node = nodes[virtualItem.index];
+
+        if (!node) {
+          return undefined;
+        }
+
+        const taskInstance = taskInstanceMap.get(node.id);
+
+        if (!taskInstance) {
+          return (
+            <Box
+              height={`${ROW_HEIGHT}px`}
+              key={`${node.id}-${run.run_id}`}
+              left={0}
+              position="absolute"
+              top={0}
+              transform={`translateY(${virtualItem.start}px)`}
+              width="18px"
+            />
+          );
+        }
+
+        return (
+          <Box
+            key={node.id}
+            left={0}
+            position="absolute"
+            top={0}
+            transform={`translateY(${virtualItem.start}px)`}
+          >
+            <GridTI
+              dagId={dagId}
+              instance={taskInstance}
+              isGroup={node.isGroup}
+              isMapped={node.is_mapped}
+              label={node.label}
+              onClick={onCellClick}
+              runId={run.run_id}
+              taskId={node.id}
+            />
+          </Box>
+        );
+      })}
+    </Box>
+  );
 };
+
+export const TaskInstancesColumn = memo(TaskInstancesColumnInner);
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/TaskNames.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/TaskNames.tsx
index 21b4c6af054..85f7fc1c788 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/TaskNames.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/TaskNames.tsx
@@ -17,137 +17,168 @@
  * under the License.
  */
 import { Box, chakra, Flex, Link } from "@chakra-ui/react";
+import type { VirtualItem } from "@tanstack/react-virtual";
 import type { MouseEvent } from "react";
+import { useCallback } from "react";
 import { useTranslation } from "react-i18next";
 import { FiChevronUp } from "react-icons/fi";
 import { Link as RouterLink, useParams, useSearchParams } from 
"react-router-dom";
 
 import { TaskName } from "src/components/TaskName";
-import { type HoverContextType, useHover } from "src/context/hover";
+import { useHover } from "src/context/hover";
 import { useOpenGroups } from "src/context/openGroups";
 
 import type { GridTask } from "./utils";
 
 type Props = {
-  depth?: number;
-  nodes: Array<GridTask>;
-  onRowClick?: () => void;
+  readonly depth?: number;
+  readonly nodes: Array<GridTask>;
+  readonly onRowClick?: () => void;
+  readonly virtualItems?: Array<VirtualItem>;
 };
 
+const ROW_HEIGHT = 20;
+
 const indent = (depth: number) => `${depth * 0.75 + 0.5}rem`;
 
-const onMouseEnter = (
-  event: MouseEvent<HTMLDivElement>,
-  nodeId: string,
-  setHoveredTaskId: HoverContextType["setHoveredTaskId"],
-) => {
-  const tasks = 
document.querySelectorAll<HTMLDivElement>(`#${event.currentTarget.id}`);
+export const TaskNames = ({ nodes, onRowClick, virtualItems }: Props) => {
+  const { t: translate } = useTranslation("dag");
+  const { hoveredTaskId, setHoveredTaskId } = useHover();
+  const { toggleGroupId } = useOpenGroups();
+  const { dagId = "", groupId, taskId } = useParams();
+  const [searchParams] = useSearchParams();
 
-  tasks.forEach((task) => {
-    task.style.backgroundColor = "var(--chakra-colors-info-subtle)";
-  });
+  const handleMouseEnter = useCallback(
+    (event: MouseEvent<HTMLDivElement>) => {
+      const { nodeId } = event.currentTarget.dataset;
 
-  setHoveredTaskId(nodeId);
-};
+      if (nodeId !== undefined) {
+        setHoveredTaskId(nodeId);
+      }
+    },
+    [setHoveredTaskId],
+  );
 
-const onMouseLeave = (nodeId: string, setHoveredTaskId: 
HoverContextType["setHoveredTaskId"]) => {
-  const tasks = 
document.querySelectorAll<HTMLDivElement>(`#task-${nodeId.replaceAll(".", 
"-")}`);
+  const handleMouseLeave = useCallback(() => setHoveredTaskId(undefined), 
[setHoveredTaskId]);
 
-  tasks.forEach((task) => {
-    task.style.backgroundColor = "";
-  });
+  const handleToggleGroup = useCallback(
+    (event: MouseEvent<HTMLSpanElement>) => {
+      event.preventDefault();
+      event.stopPropagation();
+      const groupNodeId = event.currentTarget.dataset.groupId;
 
-  setHoveredTaskId(undefined);
-};
+      if (groupNodeId !== undefined) {
+        toggleGroupId(groupNodeId);
+      }
+    },
+    [toggleGroupId],
+  );
 
-export const TaskNames = ({ nodes, onRowClick }: Props) => {
-  const { t: translate } = useTranslation("dag");
-  const { setHoveredTaskId } = useHover();
-  const { toggleGroupId } = useOpenGroups();
-  const { dagId = "", groupId, taskId } = useParams();
-  const [searchParams] = useSearchParams();
+  const search = searchParams.toString();
+
+  // If virtualItems is provided, use virtualization; otherwise render all 
items
+  const itemsToRender =
+    virtualItems ?? nodes.map((_, index) => ({ index, size: ROW_HEIGHT, start: 
index * ROW_HEIGHT }));
+
+  return (
+    <>
+      {itemsToRender.map((virtualItem) => {
+        const node = nodes[virtualItem.index];
 
-  return nodes.map((node, index) => (
-    <Box
-      bg={node.id === taskId || node.id === groupId ? "info.muted" : undefined}
-      borderBottomWidth={1}
-      borderColor={node.isGroup ? "border.emphasized" : "border"}
-      borderTopWidth={index === 0 ? 1 : 0}
-      cursor="pointer"
-      id={`task-${node.id.replaceAll(".", "-")}`}
-      key={node.id}
-      maxHeight="20px"
-      onMouseEnter={(event) => onMouseEnter(event, node.id, setHoveredTaskId)}
-      onMouseLeave={() => onMouseLeave(node.id, setHoveredTaskId)}
-      transition="background-color 0.2s"
-    >
-      {node.isGroup ? (
-        <Link asChild data-testid={node.id} display="block" width="100%">
-          <RouterLink
-            onClick={onRowClick}
-            replace
-            style={{ outline: "none" }}
-            to={{
-              pathname: `/dags/${dagId}/tasks/group/${node.id}`,
-              search: searchParams.toString(),
-            }}
+        if (!node) {
+          return undefined;
+        }
+
+        const isSelected = node.id === taskId || node.id === groupId;
+        const isHovered = hoveredTaskId === node.id;
+
+        return (
+          <Box
+            bg={isSelected ? "brand.emphasized" : isHovered ? "brand.muted" : 
undefined}
+            borderBottomWidth={1}
+            borderColor={node.isGroup ? "border.emphasized" : "border"}
+            borderTopWidth={virtualItem.index === 0 ? 1 : 0}
+            cursor="pointer"
+            data-node-id={node.id}
+            height={`${ROW_HEIGHT}px`}
+            id={`task-${node.id.replaceAll(".", "-")}`}
+            key={node.id}
+            left={0}
+            onMouseEnter={handleMouseEnter}
+            onMouseLeave={handleMouseLeave}
+            position="absolute"
+            right={0}
+            top={0}
+            transform={`translateY(${virtualItem.start}px)`}
+            transition="background-color 0.2s"
           >
-            <Flex alignItems="center" width="100%">
-              <TaskName
-                fontSize="sm"
-                fontWeight="normal"
-                isGroup={true}
-                isMapped={Boolean(node.is_mapped)}
-                label={node.label}
-                paddingLeft={indent(node.depth)}
-                setupTeardownType={node.setup_teardown_type}
-              />
-              <chakra.span
-                _focus={{ outline: "none" }}
-                alignItems="center"
-                aria-label={translate("grid.buttons.toggleGroup")}
-                cursor="pointer"
-                display="inline-flex"
-                ml={1}
-                onClick={(event) => {
-                  event.preventDefault();
-                  event.stopPropagation();
-                  toggleGroupId(node.id);
-                }}
-                px={1}
-              >
-                <FiChevronUp
-                  size={16}
-                  style={{
-                    transform: `rotate(${node.isOpen ? 0 : 180}deg)`,
-                    transition: "transform 0.5s",
+            {node.isGroup ? (
+              <Link asChild data-testid={node.id} display="block" width="100%">
+                <RouterLink
+                  onClick={onRowClick}
+                  replace
+                  style={{ outline: "none" }}
+                  to={{
+                    pathname: `/dags/${dagId}/tasks/group/${node.id}`,
+                    search,
                   }}
-                />
-              </chakra.span>
-            </Flex>
-          </RouterLink>
-        </Link>
-      ) : (
-        <Link asChild data-testid={node.id} display="inline">
-          <RouterLink
-            onClick={onRowClick}
-            replace
-            to={{
-              pathname: `/dags/${dagId}/tasks/${node.id}`,
-              search: searchParams.toString(),
-            }}
-          >
-            <TaskName
-              fontSize="sm"
-              fontWeight="normal"
-              isMapped={Boolean(node.is_mapped)}
-              label={node.label}
-              paddingLeft={indent(node.depth)}
-              setupTeardownType={node.setup_teardown_type}
-            />
-          </RouterLink>
-        </Link>
-      )}
-    </Box>
-  ));
+                >
+                  <Flex alignItems="center" width="100%">
+                    <TaskName
+                      fontSize="sm"
+                      fontWeight="normal"
+                      isGroup={true}
+                      isMapped={Boolean(node.is_mapped)}
+                      label={node.label}
+                      paddingLeft={indent(node.depth)}
+                      setupTeardownType={node.setup_teardown_type}
+                    />
+                    <chakra.span
+                      _focus={{ outline: "none" }}
+                      alignItems="center"
+                      aria-label={translate("grid.buttons.toggleGroup")}
+                      cursor="pointer"
+                      data-group-id={node.id}
+                      display="inline-flex"
+                      ml={1}
+                      onClick={handleToggleGroup}
+                      px={1}
+                    >
+                      <FiChevronUp
+                        size={16}
+                        style={{
+                          transform: `rotate(${node.isOpen ? 0 : 180}deg)`,
+                          transition: "transform 0.5s",
+                        }}
+                      />
+                    </chakra.span>
+                  </Flex>
+                </RouterLink>
+              </Link>
+            ) : (
+              <Link asChild data-testid={node.id} display="inline">
+                <RouterLink
+                  onClick={onRowClick}
+                  replace
+                  to={{
+                    pathname: `/dags/${dagId}/tasks/${node.id}`,
+                    search,
+                  }}
+                >
+                  <TaskName
+                    fontSize="sm"
+                    fontWeight="normal"
+                    isMapped={Boolean(node.is_mapped)}
+                    label={node.label}
+                    paddingLeft={indent(node.depth)}
+                    setupTeardownType={node.setup_teardown_type}
+                  />
+                </RouterLink>
+              </Link>
+            )}
+          </Box>
+        );
+      })}
+    </>
+  );
 };
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/PanelButtons.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Details/PanelButtons.tsx
index 1ac9cbaae59..7db78c9d7ef 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/PanelButtons.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/PanelButtons.tsx
@@ -208,8 +208,8 @@ export const PanelButtons = ({
   );
 
   return (
-    <Box position="absolute" px={2} ref={containerRef} top={1} width="100%" 
zIndex={1}>
-      <Flex justifyContent="space-between" pl={2}>
+    <Box position="absolute" pr={4} ref={containerRef} top={1} width="100%" 
zIndex={1}>
+      <Flex justifyContent="space-between">
         <ButtonGroup attached size="sm" variant="outline">
           <IconButton
             aria-label={translate("dag:panel.buttons.showGridShortcut")}
@@ -242,7 +242,7 @@ export const PanelButtons = ({
             <MdOutlineAccountTree />
           </IconButton>
         </ButtonGroup>
-        <Flex alignItems="center" gap={1} justifyContent="space-between" 
pl={2} pr={6}>
+        <Flex alignItems="center" gap={1} justifyContent="space-between">
           <ToggleGroups />
           {/* eslint-disable-next-line jsx-a11y/no-autofocus */}
           <Popover.Root autoFocus={false} positioning={{ placement: 
"bottom-end" }}>


Reply via email to