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

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


The following commit(s) were added to refs/heads/v3-3-test by this push:
     new 6f66c37d886 Stop re-rendering the whole Grid on every hover (#69912) 
(#69928)
6f66c37d886 is described below

commit 6f66c37d886f87672cd196c9eef14a8880b461c3
Author: Pierre Jeambrun <[email protected]>
AuthorDate: Wed Jul 15 18:02:53 2026 +0200

    Stop re-rendering the whole Grid on every hover (#69912) (#69928)
    
    Every cell in the Dag Grid view subscribed to a shared hover context, so
    hovering any cell changed the context value and re-rendered the entire grid.
    On a ~20-run x 20-task grid that measured ~3 React commits plus full-grid
    render work per hover — the interaction lag reported in the issue, which 
only
    gets worse as the Dag grows.
    
    Move the run-column / task-row crosshair highlight to a single delegated
    pointerover handler that toggles CSS classes on the matching cells by
    data-run-id / data-task-id, so hovering does no React render work at all. 
The
    same delegated root covers the gantt so the shared row highlight stays in 
sync.
    Measured on the same grid, React commits per hover dropped from ~3.0 to 
~0.07.
    
    related: #69531
    (cherry picked from commit ae6188dbc79c1445998f0290d39e634a70ff460b)
---
 .../airflow/ui/src/context/hover/HoverProvider.tsx |  31 --
 .../src/airflow/ui/src/context/hover/index.ts      |  21 --
 .../src/airflow/ui/src/context/hover/useHover.ts   |  31 --
 .../ui/src/layouts/Details/DetailsLayout.tsx       | 405 ++++++++++-----------
 .../airflow/ui/src/layouts/Details/Gantt/Gantt.tsx |  16 +-
 .../layouts/Details/Gantt/GanttTimeline.test.tsx   |   7 +-
 .../ui/src/layouts/Details/Gantt/GanttTimeline.tsx |   9 +-
 .../airflow/ui/src/layouts/Details/Grid/Bar.tsx    |  12 +-
 .../airflow/ui/src/layouts/Details/Grid/GridTI.tsx |  14 +-
 .../layouts/Details/Grid/TaskInstancesColumn.tsx   |  15 +-
 .../ui/src/layouts/Details/Grid/TaskNames.tsx      |  19 +-
 .../Details/Grid/gridHover.css}                    |  18 +-
 .../layouts/Details/Grid/useGridCrosshairHover.ts  |  95 +++++
 13 files changed, 323 insertions(+), 370 deletions(-)

diff --git a/airflow-core/src/airflow/ui/src/context/hover/HoverProvider.tsx 
b/airflow-core/src/airflow/ui/src/context/hover/HoverProvider.tsx
deleted file mode 100644
index 0afb9bea99c..00000000000
--- a/airflow-core/src/airflow/ui/src/context/hover/HoverProvider.tsx
+++ /dev/null
@@ -1,31 +0,0 @@
-/*!
- * 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 { PropsWithChildren } from "react";
-import { useState } 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 = { hoveredRunId, hoveredTaskId, setHoveredRunId, 
setHoveredTaskId };
-
-  return <HoverContext.Provider 
value={value}>{children}</HoverContext.Provider>;
-};
diff --git a/airflow-core/src/airflow/ui/src/context/hover/index.ts 
b/airflow-core/src/airflow/ui/src/context/hover/index.ts
deleted file mode 100644
index a1f52fc6f64..00000000000
--- a/airflow-core/src/airflow/ui/src/context/hover/index.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-/*!
- * 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.
- */
-export { HoverProvider } from "./HoverProvider";
-export { useHover } from "./useHover";
-export type { HoverContextType } from "./Context";
diff --git a/airflow-core/src/airflow/ui/src/context/hover/useHover.ts 
b/airflow-core/src/airflow/ui/src/context/hover/useHover.ts
deleted file mode 100644
index 541d4306d9d..00000000000
--- a/airflow-core/src/airflow/ui/src/context/hover/useHover.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-/*!
- * 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 { useContext } from "react";
-
-import { HoverContext } from "./Context";
-
-export const useHover = () => {
-  const context = useContext(HoverContext);
-
-  if (context === undefined) {
-    throw new Error("useHover must be used within a HoverProvider");
-  }
-
-  return context;
-};
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 736f81828eb..c55f3bd0d37 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/DetailsLayout.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/DetailsLayout.tsx
@@ -48,42 +48,37 @@ import { DEFAULT_DAG_VIEW_KEY } from 
"src/constants/localStorage";
 import { SearchParamsKeys } from "src/constants/searchParams";
 import { VersionIndicatorOptions } from 
"src/constants/showVersionIndicatorOptions";
 import { GroupsProvider } from "src/context/groups";
-import { HoverProvider, useHover } from "src/context/hover";
 import { useGridRuns } from "src/queries/useGridRuns.ts";
 
 import { DagBreadcrumb } from "./DagBreadcrumb";
 import { Gantt } from "./Gantt/Gantt";
 import { Graph } from "./Graph";
 import { Grid } from "./Grid";
+import { useGridCrosshairHover } from "./Grid/useGridCrosshairHover";
 import { NavTabs, type NavTab } from "./NavTabs";
 import { PanelButtons } from "./PanelButtons";
 
-// Separate component so useHover can be called inside HoverProvider.
+// Shared scroll container for the grid + gantt in the combined view.
 const SharedScrollBox = ({
   children,
   scrollRef,
 }: {
   readonly children: ReactNode;
   readonly scrollRef: RefObject<HTMLDivElement | null>;
-}) => {
-  const { setHoveredTaskId } = useHover();
-
-  return (
-    <Box
-      height="100%"
-      minH={0}
-      minW={0}
-      onMouseLeave={() => setHoveredTaskId(undefined)}
-      overflowX="hidden"
-      overflowY="auto"
-      ref={scrollRef}
-      style={{ scrollbarGutter: "stable" }}
-      w="100%"
-    >
-      {children}
-    </Box>
-  );
-};
+}) => (
+  <Box
+    height="100%"
+    minH={0}
+    minW={0}
+    overflowX="hidden"
+    overflowY="auto"
+    ref={scrollRef}
+    style={{ scrollbarGutter: "stable" }}
+    w="100%"
+  >
+    {children}
+  </Box>
+);
 
 type Props = {
   readonly error?: unknown;
@@ -99,6 +94,13 @@ export const DetailsLayout = ({ children, error, isLoading, 
outletContext, tabs
   const { data: dag } = useDagServiceGetDag({ dagId });
   const [dagView, setDagView] = useLocalStorage<DagView>(DEFAULT_DAG_VIEW_KEY, 
"grid");
   const panelGroupRef = useRef<ImperativePanelGroupHandle | null>(null);
+  // Root for the delegated grid/gantt crosshair-hover handler (covers both the
+  // grid and the gantt so their shared row highlight stays in sync, with no
+  // React re-render on hover).
+  const gridHoverRootRef = useRef<HTMLDivElement>(null);
+
+  useGridCrosshairHover(gridHoverRootRef);
+
   const [searchParams, setSearchParams] = useSearchParams();
 
   // Global setting: applies to all Dags (intentionally not scoped to dagId)
@@ -216,107 +218,70 @@ export const DetailsLayout = ({ children, error, 
isLoading, outletContext, tabs
   const defaultSize = Math.max(dagView === "graph" ? 70 : 20, minSize);
 
   return (
-    <HoverProvider>
-      <GroupsProvider dagId={dagId}>
-        <Box display="flex" flex={1} flexDirection="column" minH={0} minW={{ 
base: "1280px", md: "auto" }}>
-          <HStack justifyContent="space-between" mb={2}>
-            <Flex alignItems="center" gap={1}>
-              <DagBreadcrumb />
-            </Flex>
-            <Flex gap={1}>
-              <SearchDagsButton />
-              {dag === undefined ? undefined : (
-                <TriggerDAGButton
-                  allowedRunTypes={dag.allowed_run_types}
-                  dagDisplayName={dag.dag_display_name}
-                  dagId={dag.dag_id}
-                  isPaused={dag.is_paused}
-                  variant="outline"
-                  withText
-                />
-              )}
-            </Flex>
-          </HStack>
-          <Toaster />
-          <BackfillBanner dagId={dagId} />
-          <Box flex={1} minH={0}>
-            {isRightPanelCollapsed ? (
-              <IconButton
-                bg="fg.subtle"
-                borderRadius={direction === "ltr" ? "100% 0 0 100%" : "0 100% 
100% 0"}
-                boxShadow="md"
-                label={translate("common:showDetailsPanel")}
-                left={direction === "rtl" ? "0" : undefined}
-                onClick={() => setIsRightPanelCollapsed(false)}
-                position="absolute"
-                right={direction === "ltr" ? "0" : undefined}
-                size="2xs"
-                top="50%"
-                zIndex={10}
-              >
-                {direction === "ltr" ? <FaChevronLeft /> : <FaChevronRight />}
-              </IconButton>
-            ) : undefined}
-            <PanelGroup
-              autoSaveId={`${panelViewKey}-${direction}`}
-              dir={direction}
-              direction="horizontal"
-              key={`${panelViewKey}-${direction}`}
-              ref={panelGroupRef}
+    <GroupsProvider dagId={dagId}>
+      <Box display="flex" flex={1} flexDirection="column" minH={0} minW={{ 
base: "1280px", md: "auto" }}>
+        <HStack justifyContent="space-between" mb={2}>
+          <Flex alignItems="center" gap={1}>
+            <DagBreadcrumb />
+          </Flex>
+          <Flex gap={1}>
+            <SearchDagsButton />
+            {dag === undefined ? undefined : (
+              <TriggerDAGButton
+                allowedRunTypes={dag.allowed_run_types}
+                dagDisplayName={dag.dag_display_name}
+                dagId={dag.dag_id}
+                isPaused={dag.is_paused}
+                variant="outline"
+                withText
+              />
+            )}
+          </Flex>
+        </HStack>
+        <Toaster />
+        <BackfillBanner dagId={dagId} />
+        <Box flex={1} minH={0}>
+          {isRightPanelCollapsed ? (
+            <IconButton
+              bg="fg.subtle"
+              borderRadius={direction === "ltr" ? "100% 0 0 100%" : "0 100% 
100% 0"}
+              boxShadow="md"
+              label={translate("common:showDetailsPanel")}
+              left={direction === "rtl" ? "0" : undefined}
+              onClick={() => setIsRightPanelCollapsed(false)}
+              position="absolute"
+              right={direction === "ltr" ? "0" : undefined}
+              size="2xs"
+              top="50%"
+              zIndex={10}
             >
-              <Panel defaultSize={defaultSize} id="main-panel" 
minSize={minSize} order={1}>
-                <Flex flexDirection="column" height="100%">
-                  <PanelButtons
-                    dagView={dagView}
-                    limit={limit}
-                    panelGroupRef={panelGroupRef}
-                    setDagView={setDagView}
-                    setLimit={setLimit}
-                    setShowVersionIndicatorMode={setShowVersionIndicatorMode}
-                    showVersionIndicatorMode={showVersionIndicatorMode}
-                  />
-                  <Box flex={1} minH={0} overflow="hidden">
-                    {dagView === "graph" ? (
-                      <Graph />
-                    ) : dagView === "gantt" && Boolean(runId) ? (
-                      <SharedScrollBox scrollRef={sharedGridGanttScrollRef}>
-                        <Flex alignItems="flex-start" gap={0} maxW="100%" 
minW={0} overflow="clip" w="100%">
-                          <Grid
-                            dagRunState={dagRunStateFilter}
-                            limit={limit}
-                            offset={offset}
-                            onJumpToLatest={handleJumpToLatest}
-                            runAfterGte={runAfterGte}
-                            runAfterLte={runAfterLte}
-                            runType={runTypeFilter}
-                            setOffset={setOffset}
-                            sharedScrollContainerRef={sharedGridGanttScrollRef}
-                            showGantt
-                            showVersionIndicatorMode={showVersionIndicatorMode}
-                            triggeringUser={triggeringUserFilter}
-                          />
-                          <Gantt
-                            dagRunState={dagRunStateFilter}
-                            limit={limit}
-                            offset={offset}
-                            runAfterGte={runAfterGte}
-                            runAfterLte={runAfterLte}
-                            runType={runTypeFilter}
-                            sharedScrollContainerRef={sharedGridGanttScrollRef}
-                            triggeringUser={triggeringUserFilter}
-                          />
-                        </Flex>
-                      </SharedScrollBox>
-                    ) : (
-                      <HStack
-                        alignItems="flex-start"
-                        gap={0}
-                        height="100%"
-                        maxW="100%"
-                        minW={0}
-                        overflow="hidden"
-                        w="100%"
-                      >
+              {direction === "ltr" ? <FaChevronLeft /> : <FaChevronRight />}
+            </IconButton>
+          ) : undefined}
+          <PanelGroup
+            autoSaveId={`${panelViewKey}-${direction}`}
+            dir={direction}
+            direction="horizontal"
+            key={`${panelViewKey}-${direction}`}
+            ref={panelGroupRef}
+          >
+            <Panel defaultSize={defaultSize} id="main-panel" minSize={minSize} 
order={1}>
+              <Flex flexDirection="column" height="100%">
+                <PanelButtons
+                  dagView={dagView}
+                  limit={limit}
+                  panelGroupRef={panelGroupRef}
+                  setDagView={setDagView}
+                  setLimit={setLimit}
+                  setShowVersionIndicatorMode={setShowVersionIndicatorMode}
+                  showVersionIndicatorMode={showVersionIndicatorMode}
+                />
+                <Box flex={1} minH={0} overflow="hidden" 
ref={gridHoverRootRef}>
+                  {dagView === "graph" ? (
+                    <Graph />
+                  ) : dagView === "gantt" && Boolean(runId) ? (
+                    <SharedScrollBox scrollRef={sharedGridGanttScrollRef}>
+                      <Flex alignItems="flex-start" gap={0} maxW="100%" 
minW={0} overflow="clip" w="100%">
                         <Grid
                           dagRunState={dagRunStateFilter}
                           limit={limit}
@@ -326,98 +291,128 @@ export const DetailsLayout = ({ children, error, 
isLoading, outletContext, tabs
                           runAfterLte={runAfterLte}
                           runType={runTypeFilter}
                           setOffset={setOffset}
+                          sharedScrollContainerRef={sharedGridGanttScrollRef}
+                          showGantt
                           showVersionIndicatorMode={showVersionIndicatorMode}
                           triggeringUser={triggeringUserFilter}
                         />
-                      </HStack>
-                    )}
-                  </Box>
-                </Flex>
-              </Panel>
-              {!isRightPanelCollapsed && (
-                <>
-                  <PanelResizeHandle
-                    className="resize-handle"
-                    onDragging={(isDragging) => {
-                      if (!isDragging) {
-                        const zoom = getZoom();
+                        <Gantt
+                          dagRunState={dagRunStateFilter}
+                          limit={limit}
+                          offset={offset}
+                          runAfterGte={runAfterGte}
+                          runAfterLte={runAfterLte}
+                          runType={runTypeFilter}
+                          sharedScrollContainerRef={sharedGridGanttScrollRef}
+                          triggeringUser={triggeringUserFilter}
+                        />
+                      </Flex>
+                    </SharedScrollBox>
+                  ) : (
+                    <HStack
+                      alignItems="flex-start"
+                      gap={0}
+                      height="100%"
+                      maxW="100%"
+                      minW={0}
+                      overflow="hidden"
+                      w="100%"
+                    >
+                      <Grid
+                        dagRunState={dagRunStateFilter}
+                        limit={limit}
+                        offset={offset}
+                        onJumpToLatest={handleJumpToLatest}
+                        runAfterGte={runAfterGte}
+                        runAfterLte={runAfterLte}
+                        runType={runTypeFilter}
+                        setOffset={setOffset}
+                        showVersionIndicatorMode={showVersionIndicatorMode}
+                        triggeringUser={triggeringUserFilter}
+                      />
+                    </HStack>
+                  )}
+                </Box>
+              </Flex>
+            </Panel>
+            {!isRightPanelCollapsed && (
+              <>
+                <PanelResizeHandle
+                  className="resize-handle"
+                  onDragging={(isDragging) => {
+                    if (!isDragging) {
+                      const zoom = getZoom();
 
-                        void fitView({ maxZoom: zoom, minZoom: zoom });
-                      }
-                    }}
-                  >
-                    <Box
-                      alignItems="center"
-                      bg="border.emphasized"
-                      cursor="col-resize"
-                      display="flex"
-                      h="100%"
-                      justifyContent="center"
-                      position="relative"
-                      w={0.5}
-                    />
-                  </PanelResizeHandle>
+                      void fitView({ maxZoom: zoom, minZoom: zoom });
+                    }
+                  }}
+                >
+                  <Box
+                    alignItems="center"
+                    bg="border.emphasized"
+                    cursor="col-resize"
+                    display="flex"
+                    h="100%"
+                    justifyContent="center"
+                    position="relative"
+                    w={0.5}
+                  />
+                </PanelResizeHandle>
 
-                  {/* Collapse button positioned next to the resize handle */}
+                {/* Collapse button positioned next to the resize handle */}
 
-                  <Panel
-                    defaultSize={dagView === "graph" ? 30 : 80}
-                    id="details-panel"
-                    minSize={20}
-                    order={2}
-                  >
-                    <Box display="flex" flexDirection="column" h="100%" 
position="relative">
-                      <IconButton
-                        bg="fg.subtle"
-                        borderRadius={direction === "ltr" ? "0 100% 100% 0" : 
"100% 0 0 100%"}
-                        boxShadow="md"
-                        label={translate("common:collapseDetailsPanel")}
-                        left={direction === "ltr" ? "0" : undefined}
-                        onClick={() => setIsRightPanelCollapsed(true)}
-                        position="absolute"
-                        right={direction === "rtl" ? "0" : undefined}
-                        size="2xs"
-                        top="50%"
-                        zIndex={2}
-                      >
-                        {direction === "ltr" ? <FaChevronRight /> : 
<FaChevronLeft />}
-                      </IconButton>
-                      {children}
-                      {Boolean(error) || (warningData?.dag_warnings.length ?? 
0) > 0 ? (
-                        <>
-                          <IconButton
-                            colorPalette={Boolean(error) ? "red" : "orange"}
-                            label={`${translate("common:dagWarnings")} 
(${warningData?.total_entries ?? 0 + Number(error)})`}
-                            margin="2"
-                            marginBottom="-1"
-                            onClick={onOpen}
-                            rounded="full"
-                            variant="solid"
-                          >
-                            <LuFileWarning />
-                          </IconButton>
+                <Panel defaultSize={dagView === "graph" ? 30 : 80} 
id="details-panel" minSize={20} order={2}>
+                  <Box display="flex" flexDirection="column" h="100%" 
position="relative">
+                    <IconButton
+                      bg="fg.subtle"
+                      borderRadius={direction === "ltr" ? "0 100% 100% 0" : 
"100% 0 0 100%"}
+                      boxShadow="md"
+                      label={translate("common:collapseDetailsPanel")}
+                      left={direction === "ltr" ? "0" : undefined}
+                      onClick={() => setIsRightPanelCollapsed(true)}
+                      position="absolute"
+                      right={direction === "rtl" ? "0" : undefined}
+                      size="2xs"
+                      top="50%"
+                      zIndex={2}
+                    >
+                      {direction === "ltr" ? <FaChevronRight /> : 
<FaChevronLeft />}
+                    </IconButton>
+                    {children}
+                    {Boolean(error) || (warningData?.dag_warnings.length ?? 0) 
> 0 ? (
+                      <>
+                        <IconButton
+                          colorPalette={Boolean(error) ? "red" : "orange"}
+                          label={`${translate("common:dagWarnings")} 
(${warningData?.total_entries ?? 0 + Number(error)})`}
+                          margin="2"
+                          marginBottom="-1"
+                          onClick={onOpen}
+                          rounded="full"
+                          variant="solid"
+                        >
+                          <LuFileWarning />
+                        </IconButton>
 
-                          <DAGWarningsModal
-                            error={error}
-                            onClose={onClose}
-                            open={open}
-                            warnings={warningData?.dag_warnings}
-                          />
-                        </>
-                      ) : undefined}
-                      <ProgressBar size="xs" visibility={isLoading ? "visible" 
: "hidden"} />
-                      <NavTabs tabs={tabs} />
-                      <Box flexGrow={1} overflow="auto" px={2}>
-                        <Outlet context={outletContext} />
-                      </Box>
+                        <DAGWarningsModal
+                          error={error}
+                          onClose={onClose}
+                          open={open}
+                          warnings={warningData?.dag_warnings}
+                        />
+                      </>
+                    ) : undefined}
+                    <ProgressBar size="xs" visibility={isLoading ? "visible" : 
"hidden"} />
+                    <NavTabs tabs={tabs} />
+                    <Box flexGrow={1} overflow="auto" px={2}>
+                      <Outlet context={outletContext} />
                     </Box>
-                  </Panel>
-                </>
-              )}
-            </PanelGroup>
-          </Box>
+                  </Box>
+                </Panel>
+              </>
+            )}
+          </PanelGroup>
         </Box>
-      </GroupsProvider>
-    </HoverProvider>
+      </Box>
+    </GroupsProvider>
   );
 };
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/Gantt.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/Gantt.tsx
index 2c20ff0e2a0..6383d57623a 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/Gantt.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/Gantt.tsx
@@ -24,7 +24,6 @@ import { useParams, useSearchParams } from "react-router-dom";
 import { useGanttServiceGetGanttData } from "openapi/queries";
 import type { DagRunState, DagRunType } from "openapi/requests/types.gen";
 import { useGroups } from "src/context/groups";
-import { useHover } from "src/context/hover";
 import { useTimezone } from "src/context/timezone";
 import { NavigationModes, useNavigation } from "src/hooks/navigation";
 import {
@@ -73,7 +72,6 @@ export const Gantt = ({
   const [searchParams] = useSearchParams();
   const { openGroupIds, toggleGroupId } = useGroups();
   const { selectedTimezone } = useTimezone();
-  const { setHoveredTaskId } = useHover();
 
   const filterRoot = searchParams.get("root") ?? undefined;
   const includeUpstream = searchParams.get("upstream") === "true";
@@ -153,10 +151,6 @@ export const Gantt = ({
     return undefined;
   }
 
-  const handleStandaloneMouseLeave = () => {
-    setHoveredTaskId(undefined);
-  };
-
   const timeline =
     Boolean(selectedRun) && dagId ? (
       <GanttTimeline
@@ -184,15 +178,7 @@ export const Gantt = ({
 
   return (
     <Flex flex={1} flexDirection="column" maxW="100%" minH={0} minW={0} 
overflow="hidden">
-      <Box
-        flex={1}
-        minH={0}
-        minW={0}
-        onMouseLeave={handleStandaloneMouseLeave}
-        overflowX="hidden"
-        overflowY="auto"
-        ref={standaloneScrollRef}
-      >
+      <Box flex={1} minH={0} minW={0} overflowX="hidden" overflowY="auto" 
ref={standaloneScrollRef}>
         {timeline}
       </Box>
     </Flex>
diff --git 
a/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/GanttTimeline.test.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/GanttTimeline.test.tsx
index fc990e2a0dc..59b32551acb 100644
--- 
a/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/GanttTimeline.test.tsx
+++ 
b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/GanttTimeline.test.tsx
@@ -21,7 +21,6 @@ import type { PropsWithChildren, RefObject } from "react";
 import { createRef } from "react";
 import { afterEach, describe, expect, it, vi } from "vitest";
 
-import { HoverProvider } from "src/context/hover";
 import { ROW_HEIGHT } from "src/layouts/Details/Grid/constants";
 import type { GridTask } from "src/layouts/Details/Grid/utils";
 import { Wrapper } from "src/utils/Wrapper";
@@ -45,11 +44,7 @@ vi.mock("@tanstack/react-virtual", () => ({
   }),
 }));
 
-const TestWrapper = ({ children }: PropsWithChildren) => (
-  <Wrapper>
-    <HoverProvider>{children}</HoverProvider>
-  </Wrapper>
-);
+const TestWrapper = ({ children }: PropsWithChildren) => 
<Wrapper>{children}</Wrapper>;
 
 // Shared time range: 10:00 → 10:10 UTC on 2024-03-14
 const MIN_MS = new Date("2024-03-14T10:00:00Z").getTime();
diff --git 
a/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/GanttTimeline.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/GanttTimeline.tsx
index 43c28dba56b..b3ec2bac29d 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/GanttTimeline.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/GanttTimeline.tsx
@@ -26,7 +26,6 @@ import { Link, useLocation, useParams } from 
"react-router-dom";
 import type { LightGridTaskInstanceSummary } from "openapi/requests/types.gen";
 import { StateIcon } from "src/components/StateIcon";
 import TaskInstanceTooltip from "src/components/TaskInstanceTooltip";
-import { useHover } from "src/context/hover";
 import {
   GANTT_AXIS_HEIGHT_PX,
   GANTT_TOP_PADDING_PX,
@@ -115,7 +114,6 @@ export const GanttTimeline = ({
 }: Props) => {
   const location = useLocation();
   const { groupId: selectedGroupId, taskId: selectedTaskId } = useParams();
-  const { hoveredTaskId, setHoveredTaskId } = useHover();
   const [bodyWidthPx, setBodyWidthPx] = useState(0);
   const bodyRef = useRef<HTMLDivElement | null>(null);
 
@@ -291,7 +289,6 @@ export const GanttTimeline = ({
                 : allSegments;
             const taskId = node.id;
             const isSelected = selectedTaskId === taskId || selectedGroupId 
=== taskId;
-            const isHovered = hoveredTaskId === taskId;
             const gridSummary = summaryByTaskId.get(taskId);
 
             return (
@@ -309,11 +306,11 @@ export const GanttTimeline = ({
                 zIndex={1}
               >
                 <Box
-                  bg={isSelected ? "brand.emphasized" : isHovered ? 
"brand.muted" : undefined}
+                  bg={isSelected ? "brand.emphasized" : undefined}
+                  data-selected={isSelected}
+                  data-task-id={taskId}
                   h="100%"
                   maxW="100%"
-                  onMouseEnter={() => setHoveredTaskId(taskId)}
-                  onMouseLeave={() => setHoveredTaskId(undefined)}
                   overflow="hidden"
                   position="relative"
                   px="3px"
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 0cfce328ac4..c0757a4a81e 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
@@ -21,7 +21,6 @@ import { useParams, useSearchParams } from "react-router-dom";
 
 import { RunTypeIcon } from "src/components/RunTypeIcon";
 import { VersionIndicatorOptions } from 
"src/constants/showVersionIndicatorOptions";
-import { useHover } from "src/context/hover";
 
 import { GridButton } from "./GridButton";
 import { BundleVersionIndicator, DagVersionIndicator } from 
"./VersionIndicator";
@@ -42,20 +41,15 @@ type Props = {
 export const Bar = ({ max, onClick, run, showVersionIndicatorMode }: 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 handleMouseEnter = () => setHoveredRunId(run.run_id);
-  const handleMouseLeave = () => setHoveredRunId(undefined);
-
   return (
     <Box
-      bg={isSelected ? "brand.emphasized" : isHovered ? "brand.muted" : 
undefined}
-      onMouseEnter={handleMouseEnter}
-      onMouseLeave={handleMouseLeave}
+      bg={isSelected ? "brand.emphasized" : undefined}
+      data-run-id={run.run_id}
+      data-selected={isSelected}
       position="relative"
       transition="background-color 0.2s"
     >
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 6cd21d252f9..0baa557b4c0 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
@@ -22,7 +22,6 @@ import { Link, useLocation, useParams, useSearchParams } from 
"react-router-dom"
 import type { LightGridTaskInstanceSummary } from "openapi/requests/types.gen";
 import { StateIcon } from "src/components/StateIcon";
 import TaskInstanceTooltip from "src/components/TaskInstanceTooltip";
-import { useHover } from "src/context/hover";
 import { buildTaskInstanceUrl } from "src/utils/links";
 
 type Props = {
@@ -37,7 +36,6 @@ type Props = {
 };
 
 export const GridTI = ({ dagId, instance, isGroup, isMapped, onClick, runId, 
taskId }: Props) => {
-  const { hoveredTaskId, setHoveredTaskId } = useHover();
   const { groupId: selectedGroupId, taskId: selectedTaskId } = useParams();
   const location = useLocation();
 
@@ -52,29 +50,25 @@ export const GridTI = ({ dagId, instance, isGroup, 
isMapped, onClick, runId, tas
     taskId,
   });
 
-  const handleMouseEnter = () => setHoveredTaskId(taskId);
-  const handleMouseLeave = () => setHoveredTaskId(undefined);
-
   // 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={isSelected ? "brand.emphasized" : isHovered ? "brand.muted" : 
undefined}
+      bg={isSelected ? "brand.emphasized" : undefined}
+      data-run-id={runId}
+      data-selected={isSelected}
+      data-task-id={taskId}
       height="20px"
       id={`task-${taskId.replaceAll(".", "-")}`}
       justifyContent="center"
       key={taskId}
-      onMouseEnter={handleMouseEnter}
-      onMouseLeave={handleMouseLeave}
       position="relative"
       px="2px"
       py={0}
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 ed47572760c..c7b08c7c609 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
@@ -23,7 +23,6 @@ import { useParams } from "react-router-dom";
 import type { GridRunsResponse, GridTISummaries } from "openapi/requests";
 import type { LightGridTaskInstanceSummary } from "openapi/requests/types.gen";
 import { VersionIndicatorOptions } from 
"src/constants/showVersionIndicatorOptions";
-import { useHover } from "src/context/hover";
 
 import { GridTI } from "./GridTI";
 import { DagVersionIndicator } from "./VersionIndicator";
@@ -61,8 +60,6 @@ export const TaskInstancesColumn = ({
   const { dagId = "", runId } = useParams();
   const isSelected = runId === run.run_id;
 
-  const { hoveredRunId, setHoveredRunId } = useHover();
-
   const itemsToRender =
     virtualItems ?? nodes.map((_, index) => ({ index, size: ROW_HEIGHT, start: 
index * ROW_HEIGHT }));
 
@@ -78,17 +75,13 @@ export const TaskInstancesColumn = ({
   );
   const hasMixedVersions = versionNumbers.size > 1;
 
-  const isHovered = hoveredRunId === run.run_id;
-  const hideRowBorders = isSelected || isHovered;
-
-  const handleMouseEnter = () => setHoveredRunId(run.run_id);
-  const handleMouseLeave = () => setHoveredRunId(undefined);
+  const hideRowBorders = isSelected;
 
   return (
     <Box
-      bg={isSelected ? "brand.emphasized" : isHovered ? "brand.muted" : 
undefined}
-      onMouseEnter={handleMouseEnter}
-      onMouseLeave={handleMouseLeave}
+      bg={isSelected ? "brand.emphasized" : undefined}
+      data-run-id={run.run_id}
+      data-selected={isSelected}
       position="relative"
       transition="background-color 0.2s"
       width="18px"
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 ae8f112859a..72ca17b8112 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
@@ -25,7 +25,6 @@ import { Link as RouterLink, useParams, useSearchParams } 
from "react-router-dom
 
 import { TaskName } from "src/components/TaskName";
 import { useGroups } from "src/context/groups";
-import { useHover } from "src/context/hover";
 
 import { ROW_HEIGHT } from "./constants";
 import type { GridTask } from "./utils";
@@ -41,21 +40,10 @@ const indent = (depth: number) => `${depth * 0.75 + 
0.5}rem`;
 
 export const TaskNames = ({ nodes, onRowClick, virtualItems }: Props) => {
   const { t: translate } = useTranslation("dag");
-  const { hoveredTaskId, setHoveredTaskId } = useHover();
   const { toggleGroupId } = useGroups();
   const { dagId = "", groupId, taskId } = useParams();
   const [searchParams] = useSearchParams();
 
-  const handleMouseEnter = (event: MouseEvent<HTMLDivElement>) => {
-    const { nodeId } = event.currentTarget.dataset;
-
-    if (nodeId !== undefined) {
-      setHoveredTaskId(nodeId);
-    }
-  };
-
-  const handleMouseLeave = () => setHoveredTaskId(undefined);
-
   const handleToggleGroup = (event: MouseEvent<HTMLSpanElement>) => {
     event.preventDefault();
     event.stopPropagation();
@@ -98,23 +86,22 @@ export const TaskNames = ({ nodes, onRowClick, virtualItems 
}: Props) => {
         }
 
         const isSelected = node.id === taskId || node.id === groupId;
-        const isHovered = hoveredTaskId === node.id;
 
         return (
           <Box
-            bg={isSelected ? "brand.emphasized" : isHovered ? "brand.muted" : 
undefined}
+            bg={isSelected ? "brand.emphasized" : undefined}
             borderBottomWidth={1}
             borderColor={node.isGroup ? "border.emphasized" : "border"}
             borderTopWidth={virtualItem.index === 0 ? 1 : 0}
             cursor="pointer"
             data-node-id={node.id}
+            data-selected={isSelected}
+            data-task-id={node.id}
             data-testid={`task-${node.id.replaceAll(".", "-")}`}
             height={`${ROW_HEIGHT}px`}
             id={`task-${node.id.replaceAll(".", "-")}`}
             key={node.id}
             left={0}
-            onMouseEnter={handleMouseEnter}
-            onMouseLeave={handleMouseLeave}
             position="absolute"
             right={0}
             top={0}
diff --git a/airflow-core/src/airflow/ui/src/context/hover/Context.ts 
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/gridHover.css
similarity index 67%
rename from airflow-core/src/airflow/ui/src/context/hover/Context.ts
rename to airflow-core/src/airflow/ui/src/layouts/Details/Grid/gridHover.css
index d7c35c7e401..db7a677c69c 100644
--- a/airflow-core/src/airflow/ui/src/context/hover/Context.ts
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/gridHover.css
@@ -16,13 +16,13 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { createContext } from "react";
 
-export type HoverContextType = {
-  hoveredRunId: string | undefined;
-  hoveredTaskId: string | undefined;
-  setHoveredRunId: (runId: string | undefined) => void;
-  setHoveredTaskId: (taskId: string | undefined) => void;
-};
-
-export const HoverContext = createContext<HoverContextType | 
undefined>(undefined);
+/*
+ * Grid/Gantt hover crosshair. The classes are toggled imperatively by
+ * useGridCrosshairHover (outside the React lifecycle), so hovering never
+ * re-renders the grid. Selected cells keep their own background.
+ */
+[data-run-id].grid-hover-col:not([data-selected="true"]),
+[data-task-id].grid-hover-row:not([data-selected="true"]) {
+  background-color: var(--chakra-colors-brand-muted);
+}
diff --git 
a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/useGridCrosshairHover.ts 
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/useGridCrosshairHover.ts
new file mode 100644
index 00000000000..0125a66bbb7
--- /dev/null
+++ 
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/useGridCrosshairHover.ts
@@ -0,0 +1,95 @@
+/*!
+ * 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 { useEffect } from "react";
+import type { RefObject } from "react";
+
+import "./gridHover.css";
+
+const COL_CLASS = "grid-hover-col";
+const ROW_CLASS = "grid-hover-row";
+
+/**
+ * Highlight the hovered run column and task row (the crosshair) via direct DOM
+ * class toggling instead of React context. A single delegated ``pointerover``
+ * listener on ``rootRef`` reads the hovered cell's ``data-run-id`` /
+ * ``data-task-id`` and toggles the highlight classes on the matching elements,
+ * so moving the mouse across a large grid does zero React render work. The 
root
+ * must contain both the grid and (when present) the gantt so the shared row
+ * highlight stays in sync across the two.
+ */
+export const useGridCrosshairHover = (rootRef: RefObject<HTMLElement | null>) 
=> {
+  useEffect(() => {
+    const root = rootRef.current;
+
+    if (!root) {
+      return undefined;
+    }
+
+    let currentRun: string | undefined;
+    let currentTask: string | undefined;
+
+    const clear = (className: string) => {
+      root.querySelectorAll(`.${className}`).forEach((element) => 
element.classList.remove(className));
+    };
+
+    const apply = (attribute: string, value: string, className: string) => {
+      root
+        .querySelectorAll(`[${attribute}="${CSS.escape(value)}"]`)
+        .forEach((element) => element.classList.add(className));
+    };
+
+    const onPointerOver = (event: PointerEvent) => {
+      const target = event.target instanceof Element ? event.target : null;
+      const runId = 
target?.closest<HTMLElement>("[data-run-id]")?.dataset.runId;
+      const taskId = 
target?.closest<HTMLElement>("[data-task-id]")?.dataset.taskId;
+
+      if (runId !== currentRun) {
+        clear(COL_CLASS);
+        if (runId !== undefined) {
+          apply("data-run-id", runId, COL_CLASS);
+        }
+        currentRun = runId;
+      }
+
+      if (taskId !== currentTask) {
+        clear(ROW_CLASS);
+        if (taskId !== undefined) {
+          apply("data-task-id", taskId, ROW_CLASS);
+        }
+        currentTask = taskId;
+      }
+    };
+
+    const onPointerLeave = () => {
+      clear(COL_CLASS);
+      clear(ROW_CLASS);
+      currentRun = undefined;
+      currentTask = undefined;
+    };
+
+    root.addEventListener("pointerover", onPointerOver);
+    root.addEventListener("pointerleave", onPointerLeave);
+
+    return () => {
+      root.removeEventListener("pointerover", onPointerOver);
+      root.removeEventListener("pointerleave", onPointerLeave);
+      onPointerLeave();
+    };
+  }, [rootRef]);
+};


Reply via email to