Fix truncated details tabs and page overflow in the grid view (#68750)
* Fix truncated details tabs and page overflow in the grid view
After the Chakra UI 2.10 / React 19 bump, the grid view's height cascade
stopped resolving correctly. The `height: 100%` chain over-claims through the
Tabs/TabPanel chrome, so the details tabs render taller than their visible area
(content is cut off with no way to scroll to it), and the main column pushes the
page footer below the viewport.
Anchor the heights to the viewport instead: a shared hook measures where each
scroll container actually sits and subtracts the body's footer reserve, so every
details tab scrolls within its own area and the page itself no longer overflows.
* Mock ResizeObserver in the Jest setup for the WWW tests
The new useContentHeight hook creates a ResizeObserver, which jsdom does not
implement, so rendering any component that uses it throws
`ReferenceError: ResizeObserver is not defined` and fails the React WWW test
suite. Provide a no-op stub in the global Jest setup, next to the existing
matchMedia mock.
* Address PR comments
* Fix grid detail tabs leaving blank space below their content
When the grid panel is floored to its minimum height and grows taller than
the viewport, its detail tabs (graph, event log, and others) were left with a
blank strip beneath their content: each tab sized itself against the viewport
fold rather than the panel it lives in, so it stopped short of the panel's
actual bottom instead of filling the space it was given.
diff --git a/airflow/www/jest-globals-setup.js b/airflow/www/jest-globals-setup.js
index e24035a..9451578 100644
--- a/airflow/www/jest-globals-setup.js
+++ b/airflow/www/jest-globals-setup.js
@@ -45,6 +45,16 @@
};
}
+// Mock ResizeObserver (not implemented in jsdom) — used by useContentHeight
+if (typeof globalThis.ResizeObserver === "undefined") {
+ globalThis.ResizeObserver = function ResizeObserver(callback) {
+ this.callback = callback;
+ this.observe = () => {};
+ this.unobserve = () => {};
+ this.disconnect = () => {};
+ };
+}
+
// Unwrap React 19 AggregateError for readable test failure messages
const OrigAggregateError = globalThis.AggregateError;
if (OrigAggregateError) {
diff --git a/airflow/www/static/js/dag/Main.tsx b/airflow/www/static/js/dag/Main.tsx
index 25fdcd4..7665d27 100644
--- a/airflow/www/static/js/dag/Main.tsx
+++ b/airflow/www/static/js/dag/Main.tsx
@@ -35,7 +35,7 @@
import { FaExpandArrowsAlt, FaCompressArrowsAlt } from "react-icons/fa";
import { useGridData } from "src/api";
-import { hoverDelay } from "src/utils";
+import { hoverDelay, useContentHeight } from "src/utils";
import ShortcutCheatSheet from "src/components/ShortcutCheatSheet";
import { useKeysPress } from "src/utils/useKeysPress";
@@ -56,21 +56,6 @@
hoverDelay,
);
-const footerHeight =
- parseInt(
- getComputedStyle(
- document.getElementsByTagName("body")[0],
- ).paddingBottom.replace("px", ""),
- 10,
- ) || 0;
-const headerHeight =
- parseInt(
- getComputedStyle(
- document.getElementsByTagName("body")[0],
- ).paddingTop.replace("px", ""),
- 10,
- ) || 0;
-
const Main = () => {
const {
data: { groups },
@@ -85,6 +70,8 @@
const gridRef = useRef<HTMLDivElement>(null);
const gridScrollRef = useRef<HTMLDivElement>(null);
const ganttScrollRef = useRef<HTMLDivElement>(null);
+ const mainRef = useRef<HTMLDivElement>(null);
+ const mainHeight = useContentHeight(mainRef);
const [hoveredTaskState, setHoveredTaskState] = useState<
string | null | undefined
@@ -199,11 +186,11 @@
return (
<Box
flex={1}
- height={`calc(100vh - ${footerHeight + headerHeight}px)`}
- maxHeight={`calc(100vh - ${footerHeight + headerHeight}px)`}
- minHeight="750px"
+ ref={mainRef}
+ height={`${mainHeight}px`}
overflow="hidden"
position="relative"
+ minHeight="750px"
>
<Accordion allowToggle index={accordionIndexes} borderTopWidth={0}>
<AccordionItem
diff --git a/airflow/www/static/js/dag/details/EventLog.tsx b/airflow/www/static/js/dag/details/EventLog.tsx
index 735a193..93f7d4a 100644
--- a/airflow/www/static/js/dag/details/EventLog.tsx
+++ b/airflow/www/static/js/dag/details/EventLog.tsx
@@ -43,7 +43,7 @@
} from "chakra-react-select";
import { useEventLogs } from "src/api";
-import { getMetaValue, useOffsetTop } from "src/utils";
+import { getMetaValue, useContentHeight } from "src/utils";
import type { DagRun } from "src/types";
import LinkButton from "src/components/LinkButton";
import type { EventLog as EventLogType } from "src/types/api-generated";
@@ -75,7 +75,7 @@
const EventLog = ({ taskId, run, showMapped }: Props) => {
const logRef = useRef<HTMLDivElement>(null);
- const offsetTop = useOffsetTop(logRef);
+ const contentHeight = useContentHeight(logRef);
const { tableURLState, setTableURLState } = useTableURLState({
sorting: [{ id: "when", desc: true }],
});
@@ -216,7 +216,7 @@
return (
<Box
height="100%"
- maxHeight={`calc(100% - ${offsetTop}px)`}
+ maxHeight={`${contentHeight}px`}
ref={logRef}
overflowY="auto"
>
diff --git a/airflow/www/static/js/dag/details/dag/Dag.tsx b/airflow/www/static/js/dag/details/dag/Dag.tsx
index c3ecefe..5d046c3 100644
--- a/airflow/www/static/js/dag/details/dag/Dag.tsx
+++ b/airflow/www/static/js/dag/details/dag/Dag.tsx
@@ -41,7 +41,7 @@
getMetaValue,
getTaskSummary,
toSentenceCase,
- useOffsetTop,
+ useContentHeight,
} from "src/utils";
import { useGridData, useDagDetails } from "src/api";
import Time from "src/components/Time";
@@ -61,7 +61,7 @@
data: { dagRuns, groups },
} = useGridData();
const detailsRef = useRef<HTMLDivElement>(null);
- const offsetTop = useOffsetTop(detailsRef);
+ const contentHeight = useContentHeight(detailsRef);
const { data: dagDetailsData, isLoading: isLoadingDagDetails } =
useDagDetails();
@@ -160,7 +160,7 @@
return (
<Box
height="100%"
- maxHeight={`calc(100% - ${offsetTop}px)`}
+ maxHeight={`${contentHeight}px`}
ref={detailsRef}
overflowY="auto"
>
diff --git a/airflow/www/static/js/dag/details/dagCode/index.tsx b/airflow/www/static/js/dag/details/dagCode/index.tsx
index 1ade686..37a72da 100644
--- a/airflow/www/static/js/dag/details/dagCode/index.tsx
+++ b/airflow/www/static/js/dag/details/dagCode/index.tsx
@@ -20,7 +20,7 @@
import React, { useRef } from "react";
import { Box, Heading, Spinner } from "@chakra-ui/react";
-import { useOffsetTop } from "src/utils";
+import { useContentHeight } from "src/utils";
import Time from "src/components/Time";
import { useDag, useDagCode } from "src/api";
import ErrorAlert from "src/components/ErrorAlert";
@@ -29,7 +29,7 @@
const DagCode = () => {
const dagCodeRef = useRef<HTMLDivElement>(null);
- const offsetTop = useOffsetTop(dagCodeRef);
+ const contentHeight = useContentHeight(dagCodeRef);
const { data: dagData, isLoading: isLoadingDag, error: dagError } = useDag();
const {
data: codeSource = "",
@@ -41,7 +41,7 @@
const error = codeError || dagError;
return (
- <Box ref={dagCodeRef} height={`calc(100% - ${offsetTop}px)`}>
+ <Box ref={dagCodeRef} height={`${contentHeight}px`}>
{dagData?.lastParsedTime && (
<Heading as="h4" size="md" paddingBottom="10px" fontSize="14px">
Parsed at: <Time dateTime={dagData.lastParsedTime} />
diff --git a/airflow/www/static/js/dag/details/dagRun/index.tsx b/airflow/www/static/js/dag/details/dagRun/index.tsx
index 05679a5..9a3b762 100644
--- a/airflow/www/static/js/dag/details/dagRun/index.tsx
+++ b/airflow/www/static/js/dag/details/dagRun/index.tsx
@@ -20,7 +20,7 @@
import { Box } from "@chakra-ui/react";
import { useGridData } from "src/api";
-import { getMetaValue, useOffsetTop } from "src/utils";
+import { getMetaValue, useContentHeight } from "src/utils";
import type { DagRun as DagRunType } from "src/types";
import NotesAccordion from "src/dag/details/NotesAccordion";
@@ -38,7 +38,7 @@
data: { dagRuns },
} = useGridData();
const detailsRef = useRef<HTMLDivElement>(null);
- const offsetTop = useOffsetTop(detailsRef);
+ const contentHeight = useContentHeight(detailsRef);
const run = dagRuns.find((dr) => dr.runId === runId);
@@ -47,7 +47,7 @@
return (
<Box
- maxHeight={`calc(100% - ${offsetTop}px)`}
+ maxHeight={`${contentHeight}px`}
ref={detailsRef}
overflowY="auto"
pb={4}
diff --git a/airflow/www/static/js/dag/details/graph/index.tsx b/airflow/www/static/js/dag/details/graph/index.tsx
index 0ce05df..2d39dd0 100644
--- a/airflow/www/static/js/dag/details/graph/index.tsx
+++ b/airflow/www/static/js/dag/details/graph/index.tsx
@@ -39,7 +39,7 @@
useUpstreamDatasetEvents,
} from "src/api";
import useSelection from "src/dag/useSelection";
-import { getMetaValue, getTask, useOffsetTop } from "src/utils";
+import { getMetaValue, getTask, useContentHeight } from "src/utils";
import { useGraphLayout } from "src/utils/graph";
import Edge from "src/components/Graph/Edge";
import type { DepNode, WebserverEdge } from "src/types";
@@ -244,7 +244,7 @@
const { colors } = useTheme();
const { getZoom, fitView } = useReactFlow();
const latestDagRunId = dagRuns[dagRuns.length - 1]?.runId;
- const offsetTop = useOffsetTop(graphRef);
+ const contentHeight = useContentHeight(graphRef);
useOnViewportChange({
onEnd: (viewport: Viewport) => {
@@ -312,11 +312,11 @@
return (
<Box
ref={graphRef}
- height={`calc(100% - ${offsetTop}px)`}
+ height={`${contentHeight}px`}
borderWidth={1}
borderColor="gray.200"
>
- {!!offsetTop && (
+ {!!contentHeight && (
<ReactFlow
nodes={nodes}
edges={edges}
diff --git a/airflow/www/static/js/dag/details/taskInstance/Logs/LogBlock.tsx b/airflow/www/static/js/dag/details/taskInstance/Logs/LogBlock.tsx
index e714c9c..4b3e62f 100644
--- a/airflow/www/static/js/dag/details/taskInstance/Logs/LogBlock.tsx
+++ b/airflow/www/static/js/dag/details/taskInstance/Logs/LogBlock.tsx
@@ -19,7 +19,7 @@
import React, { useRef, useEffect, useState } from "react";
import { Code } from "@chakra-ui/react";
-import { useOffsetTop } from "src/utils";
+import { useContentHeight } from "src/utils";
interface Props {
parsedLogs: string;
@@ -37,7 +37,7 @@
const [autoScroll, setAutoScroll] = useState(true);
const logBoxRef = useRef<HTMLPreElement>(null);
- const offsetTop = useOffsetTop(logBoxRef);
+ const contentHeight = useContentHeight(logBoxRef);
const scrollToBottom = () => {
if (logBoxRef.current) {
@@ -47,13 +47,13 @@
useEffect(() => {
// Always scroll to bottom when wrap or tryNumber change
- if (offsetTop) scrollToBottom();
- }, [wrap, tryNumber, offsetTop]);
+ if (contentHeight) scrollToBottom();
+ }, [wrap, tryNumber, contentHeight]);
useEffect(() => {
// When logs change, only scroll if autoScroll is enabled
- if (autoScroll && offsetTop) scrollToBottom();
- }, [parsedLogs, autoScroll, offsetTop]);
+ if (autoScroll && contentHeight) scrollToBottom();
+ }, [parsedLogs, autoScroll, contentHeight]);
const onScroll = (e: React.UIEvent<HTMLDivElement>) => {
if (e.currentTarget) {
@@ -103,7 +103,7 @@
ref={logBoxRef}
onScroll={onScroll}
onClick={onClick}
- maxHeight={`calc(100% - ${offsetTop}px)`}
+ maxHeight={`${contentHeight}px`}
overflowY="auto"
p={3}
display="block"
diff --git a/airflow/www/static/js/dag/details/taskInstance/MappedInstances.tsx b/airflow/www/static/js/dag/details/taskInstance/MappedInstances.tsx
index d126a11..af70b70 100644
--- a/airflow/www/static/js/dag/details/taskInstance/MappedInstances.tsx
+++ b/airflow/www/static/js/dag/details/taskInstance/MappedInstances.tsx
@@ -27,7 +27,7 @@
import { StatusWithNotes } from "src/dag/StatusBox";
import { Table, CellProps } from "src/components/Table";
import Time from "src/components/Time";
-import { useOffsetTop } from "src/utils";
+import { useContentHeight } from "src/utils";
interface Props {
dagId: string;
@@ -38,7 +38,7 @@
const MappedInstances = ({ dagId, runId, taskId, onRowClicked }: Props) => {
const mappedTasksRef = useRef<HTMLDivElement>(null);
- const offsetTop = useOffsetTop(mappedTasksRef);
+ const contentHeight = useContentHeight(mappedTasksRef);
const limit = 25;
const [offset, setOffset] = useState(0);
const [sortBy, setSortBy] = useState<SortingRule<object>[]>([]);
@@ -125,11 +125,7 @@
);
return (
- <Box
- ref={mappedTasksRef}
- maxHeight={`calc(100% - ${offsetTop}px)`}
- overflowY="auto"
- >
+ <Box ref={mappedTasksRef} maxHeight={`${contentHeight}px`} overflowY="auto">
<Table
data={data}
columns={columns}
diff --git a/airflow/www/static/js/dag/details/taskInstance/RenderedK8s.tsx b/airflow/www/static/js/dag/details/taskInstance/RenderedK8s.tsx
index 459d191..633a44a 100644
--- a/airflow/www/static/js/dag/details/taskInstance/RenderedK8s.tsx
+++ b/airflow/www/static/js/dag/details/taskInstance/RenderedK8s.tsx
@@ -21,7 +21,7 @@
import { Code } from "@chakra-ui/react";
import YAML from "json-to-pretty-yaml";
-import { getMetaValue, useOffsetTop } from "src/utils";
+import { getMetaValue, useContentHeight } from "src/utils";
import useSelection from "src/dag/useSelection";
import { useRenderedK8s } from "src/api";
@@ -36,17 +36,12 @@
const { data: renderedK8s } = useRenderedK8s(runId, taskId, mapIndex);
const k8sRef = useRef<HTMLPreElement>(null);
- const offsetTop = useOffsetTop(k8sRef);
+ const contentHeight = useContentHeight(k8sRef);
if (!isK8sExecutor || !runId || !taskId) return null;
return (
- <Code
- mt={3}
- ref={k8sRef}
- maxHeight={`calc(100% - ${offsetTop}px)`}
- overflowY="auto"
- >
+ <Code mt={3} ref={k8sRef} maxHeight={`${contentHeight}px`} overflowY="auto">
<pre>{YAML.stringify(renderedK8s)}</pre>
</Code>
);
diff --git a/airflow/www/static/js/dag/details/taskInstance/Xcom/index.tsx b/airflow/www/static/js/dag/details/taskInstance/Xcom/index.tsx
index 00892fa..0b51293 100644
--- a/airflow/www/static/js/dag/details/taskInstance/Xcom/index.tsx
+++ b/airflow/www/static/js/dag/details/taskInstance/Xcom/index.tsx
@@ -31,7 +31,7 @@
import type { Dag, DagRun, TaskInstance } from "src/types";
import { useTaskXcomCollection } from "src/api";
-import { useOffsetTop } from "src/utils";
+import { useContentHeight } from "src/utils";
import ErrorAlert from "src/components/ErrorAlert";
import XcomEntry from "./XcomEntry";
@@ -52,7 +52,7 @@
tryNumber,
}: Props) => {
const taskXcomRef = useRef<HTMLDivElement>(null);
- const offsetTop = useOffsetTop(taskXcomRef);
+ const contentHeight = useContentHeight(taskXcomRef);
const {
data: xcomCollection,
@@ -70,7 +70,7 @@
<Box
ref={taskXcomRef}
height="100%"
- maxHeight={`calc(100% - ${offsetTop}px)`}
+ maxHeight={`${contentHeight}px`}
overflowY="auto"
>
{isLoading && <Spinner size="xl" thickness="4px" speed="0.65s" />}
diff --git a/airflow/www/static/js/dag/details/taskInstance/index.tsx b/airflow/www/static/js/dag/details/taskInstance/index.tsx
index 74f3178..7ef972e 100644
--- a/airflow/www/static/js/dag/details/taskInstance/index.tsx
+++ b/airflow/www/static/js/dag/details/taskInstance/index.tsx
@@ -21,7 +21,7 @@
import { Box } from "@chakra-ui/react";
import { useGridData, useTaskInstance } from "src/api";
-import { getMetaValue, getTask, useOffsetTop } from "src/utils";
+import { getMetaValue, getTask, useContentHeight } from "src/utils";
import type { DagRun, TaskInstance as GridTaskInstance } from "src/types";
import NotesAccordion from "src/dag/details/NotesAccordion";
@@ -43,7 +43,7 @@
const TaskInstance = ({ taskId, runId, mapIndex }: Props) => {
const taskInstanceRef = useRef<HTMLDivElement>(null);
- const offsetTop = useOffsetTop(taskInstanceRef);
+ const contentHeight = useContentHeight(taskInstanceRef);
const isMapIndexDefined = !(mapIndex === undefined);
const {
data: { dagRuns, groups },
@@ -79,8 +79,7 @@
return (
<Box
py="4px"
- height="100%"
- maxHeight={`calc(100% - ${offsetTop}px)`}
+ height={`${contentHeight}px`}
ref={taskInstanceRef}
overflowY="auto"
>
diff --git a/airflow/www/static/js/utils/index.ts b/airflow/www/static/js/utils/index.ts
index 23862e1..af10a64 100644
--- a/airflow/www/static/js/utils/index.ts
+++ b/airflow/www/static/js/utils/index.ts
@@ -21,6 +21,7 @@
import type { DagRun, RunOrdering, Task, TaskInstance } from "src/types";
import { LogLevel } from "src/dag/details/taskInstance/Logs/utils";
+import useContentHeight from "./useContentHeight";
import useOffsetTop from "./useOffsetTop";
// Delay in ms for various hover actions
@@ -232,6 +233,7 @@
getTaskSummary,
getDagRunLabel,
getStatusBackgroundColor,
+ useContentHeight,
useOffsetTop,
toSentenceCase,
highlightByKeywords,
diff --git a/airflow/www/static/js/utils/useContentHeight.ts b/airflow/www/static/js/utils/useContentHeight.ts
new file mode 100644
index 0000000..fbfcb8b
--- /dev/null
+++ b/airflow/www/static/js/utils/useContentHeight.ts
@@ -0,0 +1,69 @@
+/*!
+ * 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 React, { useLayoutEffect, useState } from "react";
+
+// Vertical space available to a scroll container, measured down to the bottom of the nearest
+// min-height-constrained ancestor — the grid's Main box, which has a min-height floor. Anchoring to
+// that box (rather than the viewport) lets the content grow to fill the floored area even when the
+// box is taller than the viewport and the page scrolls, instead of shrinking to the fold and
+// leaving a blank strip below. When there is no such ancestor (the Main box measuring itself), it
+// falls back to the viewport minus the body's bottom padding (the Flask footer reserve). Either way
+// this avoids the `height: 100%` cascade, which over-claims because <Tabs>/<TabPanel> have sibling
+// chrome (tab headers) stacked above the panel — that cascade is what truncated the details tabs.
+const useContentHeight = (contentRef: React.RefObject<HTMLElement>) => {
+ const [height, setHeight] = useState(0);
+
+ useLayoutEffect(() => {
+ const update = () => {
+ const element = contentRef.current;
+ if (!element) return;
+ let container = element.parentElement;
+ while (
+ container &&
+ !(parseFloat(getComputedStyle(container).minHeight) > 0)
+ ) {
+ container = container.parentElement;
+ }
+ const bottom = container
+ ? container.getBoundingClientRect().bottom
+ : window.innerHeight -
+ (parseFloat(getComputedStyle(document.body).paddingBottom) || 0);
+ const available = Math.max(
+ bottom - element.getBoundingClientRect().top,
+ 0,
+ );
+ // Guard against re-setting the same value (avoids ResizeObserver feedback loops).
+ setHeight((previous) => (previous === available ? previous : available));
+ };
+
+ update();
+ window.addEventListener("resize", update);
+ const observer = new ResizeObserver(update);
+ observer.observe(document.body);
+ return () => {
+ window.removeEventListener("resize", update);
+ observer.disconnect();
+ };
+ }, [contentRef]);
+
+ return height;
+};
+
+export default useContentHeight;