This is an automated email from the ASF dual-hosted git repository.
bbovenzi pushed a commit to branch v3-2-test
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/v3-2-test by this push:
new 54973687a13 [v3-2-test] Improve Graph View Performance (#65031)
(#65537)
54973687a13 is described below
commit 54973687a1374fa44b01017f1bd9ab0bc64f8cde
Author: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Tue Apr 21 17:06:07 2026 -0400
[v3-2-test] Improve Graph View Performance (#65031) (#65537)
* Add various improvements to graph view
* fix tests
(cherry picked from commit f6a354d970d08ccbf1e7ad51eed6221870804cd2)
Co-authored-by: Brent Bovenzi <[email protected]>
---
.../ui/public/i18n/locales/en/components.json | 3 +-
.../src/airflow/ui/src/components/Graph/Edge.tsx | 22 +-
.../ui/src/components/Graph/elkGraphUtils.ts | 361 +++++++++++++++++++++
.../ui/src/components/Graph/reactflowUtils.ts | 51 +--
.../ui/src/components/Graph/useGraphLayout.ts | 243 ++------------
.../airflow/ui/src/layouts/Details/Graph/Graph.tsx | 184 ++++++++---
airflow-core/src/airflow/ui/testsSetup.ts | 13 +
7 files changed, 568 insertions(+), 309 deletions(-)
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/components.json
b/airflow-core/src/airflow/ui/public/i18n/locales/en/components.json
index 299f3852b1a..c8fecd9b7b8 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/components.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/components.json
@@ -92,7 +92,8 @@
"otherDagRuns": "+Other Dag Runs",
"taskCount_one": "{{count}} Task",
"taskCount_other": "{{count}} Tasks",
- "taskGroup": "Task Group"
+ "taskGroup": "Task Group",
+ "zoomToTask": "Zoom to selected task"
},
"limitedList": "+{{count}} more",
"limitedList.allItems": "All {{count}} items:",
diff --git a/airflow-core/src/airflow/ui/src/components/Graph/Edge.tsx
b/airflow-core/src/airflow/ui/src/components/Graph/Edge.tsx
index 108c3c03496..244d440d10b 100644
--- a/airflow-core/src/airflow/ui/src/components/Graph/Edge.tsx
+++ b/airflow-core/src/airflow/ui/src/components/Graph/Edge.tsx
@@ -20,32 +20,32 @@ import { Text, useToken } from "@chakra-ui/react";
import { Group } from "@visx/group";
import { LinePath } from "@visx/shape";
import type { Edge as EdgeType } from "@xyflow/react";
+import { useNodesData } from "@xyflow/react";
import type { ElkPoint } from "elkjs";
import type { EdgeData } from "./reactflowUtils";
type Props = EdgeType<EdgeData>;
-const CustomEdge = ({ data }: Props) => {
+const CustomEdge = ({ data, source, target }: Props) => {
const [strokeColor, blueColor, dataEdgeColor] = useToken("colors", [
"border.inverted",
"blue.500",
"purple.500",
]);
+ // Read isSelected directly from the node store so that selection changes
+ // don't require the parent to rebuild and pass down a new edges array.
+ // useNodesData subscribes to data changes for these specific node IDs only.
+ const nodesData = useNodesData([source, target]);
+ const isSelected = nodesData.some((node) => Boolean(node.data.isSelected));
+
if (data === undefined) {
return undefined;
}
const { rest } = data;
- // Determine edge color based on type
- const getEdgeColor = () => {
- if (rest.isSelected) {
- return rest.edgeType === "data" ? dataEdgeColor : blueColor;
- }
-
- return strokeColor;
- };
+ const edgeStrokeColor = isSelected ? (rest.edgeType === "data" ?
dataEdgeColor : blueColor) : strokeColor;
return (
<>
@@ -73,9 +73,9 @@ const CustomEdge = ({ data }: Props) => {
<LinePath
data={[section.startPoint, ...(section.bendPoints ?? []),
section.endPoint]}
key={section.id}
- stroke={getEdgeColor()}
+ stroke={edgeStrokeColor}
strokeDasharray={rest.isSetupTeardown ? "10,5" : undefined}
- strokeWidth={rest.isSelected ? 3 : 1}
+ strokeWidth={isSelected ? 3 : 1}
x={(point: ElkPoint) => point.x}
y={(point: ElkPoint) => point.y}
/>
diff --git a/airflow-core/src/airflow/ui/src/components/Graph/elkGraphUtils.ts
b/airflow-core/src/airflow/ui/src/components/Graph/elkGraphUtils.ts
new file mode 100644
index 00000000000..0ab685cf37e
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/components/Graph/elkGraphUtils.ts
@@ -0,0 +1,361 @@
+/*!
+ * 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 { ElkNode, ElkExtendedEdge, ElkShape } from "elkjs";
+
+import type { EdgeResponse, NodeResponse } from "openapi/requests/types.gen";
+
+import type { Direction } from "./useGraphLayout";
+
+// ---------------------------------------------------------------------------
+// Types
+// ---------------------------------------------------------------------------
+
+type EdgeLabel = {
+ height: number;
+ id: string;
+ text: string;
+ width: number;
+};
+
+export type FormattedEdge = {
+ id: string;
+ isSetupTeardown?: boolean;
+ labels?: Array<EdgeLabel>;
+ parentNode?: string;
+} & ElkExtendedEdge;
+
+export type FormattedNode = {
+ assetCondition?: NodeResponse["asset_condition_type"];
+ childCount?: number;
+ edges?: Array<FormattedEdge>;
+ isGroup: boolean;
+ isMapped?: boolean;
+ isOpen?: boolean;
+ setupTeardownType?: NodeResponse["setup_teardown_type"];
+} & ElkShape &
+ NodeResponse;
+
+export type GenerateElkProps = {
+ direction: Direction;
+ edges: Array<EdgeResponse>;
+ font: string;
+ nodes: Array<NodeResponse>;
+ openGroupIds?: Array<string>;
+};
+
+// ---------------------------------------------------------------------------
+// Canvas singleton for text measurement
+// ---------------------------------------------------------------------------
+
+// Single reusable canvas context — avoids allocating a new HTMLCanvasElement
+// on every call (once per node per layout run).
+const canvasContext = document.createElement("canvas").getContext("2d");
+
+const getTextWidth = (text: string, font: string) => {
+ if (canvasContext) {
+ canvasContext.font = font;
+
+ return Math.max(200, canvasContext.measureText(text).width);
+ }
+
+ return Math.max(200, text.length * 9);
+};
+
+// ---------------------------------------------------------------------------
+// Edge helpers
+// ---------------------------------------------------------------------------
+
+const formatElkEdge = (edge: EdgeResponse, font: string, node?: NodeResponse):
FormattedEdge => ({
+ id: `${edge.source_id}-${edge.target_id}`,
+ isSetupTeardown: edge.is_setup_teardown === null ? undefined :
edge.is_setup_teardown,
+ labels:
+ edge.label === undefined || edge.label === null
+ ? []
+ : [
+ {
+ height: 16,
+ id: edge.label,
+ text: edge.label,
+ width: getTextWidth(edge.label, font),
+ },
+ ],
+ parentNode: node?.id,
+ sources: [edge.source_id],
+ targets: [edge.target_id],
+});
+
+/**
+ * Returns true when every child task that has at least one external connection
+ * shares exactly the same set of external sources AND the same set of external
+ * targets as every other externally-connected child.
+ *
+ * Example — a "cleanup" group where every task fans out from one upstream node
+ * and funnels into the same downstream node:
+ *
+ * upstream → T1 ─┐
+ * upstream → T2 ─┼→ downstream
+ * upstream → T3 ─┘
+ *
+ * Rendering N individual crossing edges adds visual noise without conveying
+ * any extra information beyond "the group connects upstream → downstream".
+ * When this returns true, the caller collapses those N edges to a single
+ * group-level edge while still rendering the children inside the group.
+ *
+ * Uses the original, unmodified edge list so that prior sibling group
+ * transformations do not affect the connectivity check.
+ */
+export const hasUniformExternalConnectivity = (
+ childIdSet: Set<string>,
+ edges: Array<EdgeResponse>,
+): boolean => {
+ const sourcesPerChild = new Map<string, Set<string>>();
+ const targetsPerChild = new Map<string, Set<string>>();
+
+ for (const edge of edges) {
+ const sourceIsChild = childIdSet.has(edge.source_id);
+ const targetIsChild = childIdSet.has(edge.target_id);
+
+ if (!sourceIsChild && targetIsChild) {
+ const existing = sourcesPerChild.get(edge.target_id) ?? new
Set<string>();
+
+ existing.add(edge.source_id);
+ sourcesPerChild.set(edge.target_id, existing);
+ }
+
+ if (sourceIsChild && !targetIsChild) {
+ const existing = targetsPerChild.get(edge.source_id) ?? new
Set<string>();
+
+ existing.add(edge.target_id);
+ targetsPerChild.set(edge.source_id, existing);
+ }
+ }
+
+ // Need at least 2 children with external connections on at least one side
+ // for the optimisation to be worthwhile.
+ if (sourcesPerChild.size < 2 && targetsPerChild.size < 2) {
+ return false;
+ }
+
+ // Build the union of all external sources / targets across all children.
+ const allSources = new Set<string>();
+ const allTargets = new Set<string>();
+
+ for (const sources of sourcesPerChild.values()) {
+ for (const source of sources) {
+ allSources.add(source);
+ }
+ }
+ for (const targets of targetsPerChild.values()) {
+ for (const target of targets) {
+ allTargets.add(target);
+ }
+ }
+
+ // Every child's external sources must equal allSources (same size sufficient
+ // given allSources is already the union — a child with fewer differs in
size).
+ for (const sources of sourcesPerChild.values()) {
+ if (sources.size !== allSources.size) {
+ return false;
+ }
+ }
+ for (const targets of targetsPerChild.values()) {
+ if (targets.size !== allTargets.size) {
+ return false;
+ }
+ }
+
+ return true;
+};
+
+// ---------------------------------------------------------------------------
+// Edge rewriting helper
+// ---------------------------------------------------------------------------
+
+/**
+ * Given the current working edge list, drops purely-internal edges, rewrites
+ * crossing edges so both endpoints reference `groupId` instead of a child
node,
+ * then deduplicates the result so N rewritten edges collapse to one per
+ * (source, target) pair.
+ */
+const rewriteGroupEdges = (
+ edges: Array<EdgeResponse>,
+ childIdSet: Set<string>,
+ groupId: string,
+): Array<EdgeResponse> => {
+ const seen = new Set<string>();
+
+ return edges
+ .filter((fe) => !(childIdSet.has(fe.source_id) &&
childIdSet.has(fe.target_id)))
+ .map((fe) => ({
+ ...fe,
+ source_id: childIdSet.has(fe.source_id) ? groupId : fe.source_id,
+ target_id: childIdSet.has(fe.target_id) ? groupId : fe.target_id,
+ }))
+ .filter((fe) => {
+ const key = `${fe.source_id}-${fe.target_id}`;
+
+ if (seen.has(key)) {
+ return false;
+ }
+ seen.add(key);
+
+ return true;
+ });
+};
+
+// ---------------------------------------------------------------------------
+// Node helpers
+// ---------------------------------------------------------------------------
+
+const getNestedChildIds = (children: Array<NodeResponse>): Array<string> => {
+ const childIds: Array<string> = [];
+
+ for (const child of children) {
+ childIds.push(child.id);
+ if (child.children) {
+ childIds.push(...getNestedChildIds(child.children));
+ }
+ }
+
+ return childIds;
+};
+
+// ---------------------------------------------------------------------------
+// Main graph builder
+// ---------------------------------------------------------------------------
+
+export const generateElkGraph = ({
+ direction,
+ edges: unformattedEdges,
+ font,
+ nodes,
+ openGroupIds,
+}: GenerateElkProps): ElkNode => {
+ let filteredEdges = unformattedEdges;
+
+ const formatChildNode = (node: NodeResponse): FormattedNode => {
+ const isOpen = openGroupIds?.includes(node.id);
+ const childCount = node.children?.filter((child) => child.type !==
"join").length ?? 0;
+ const childIds =
+ node.children === null || node.children === undefined ? [] :
getNestedChildIds(node.children);
+
+ if (isOpen && node.children !== null && node.children !== undefined) {
+ const childIdSet = new Set(childIds);
+
+ // Process children first — their formatChildNode calls may modify
filteredEdges
+ // (removing edges that belong to nested open groups).
+ const formattedChildren = node.children.map(formatChildNode);
+
+ // If every externally-connected task shares the same upstream source(s)
+ // and downstream target(s), collapse N crossing edges to one group-level
+ // edge (same as a closed group) while keeping the children visible.
+ // Checked against unformattedEdges so prior sibling transforms don't
interfere.
+ if (hasUniformExternalConnectivity(childIdSet, unformattedEdges)) {
+ filteredEdges = rewriteGroupEdges(filteredEdges, childIdSet, node.id);
+ }
+
+ // Extract any remaining internal edges (both endpoints inside this
group).
+ const internalEdges: Array<FormattedEdge> = [];
+
+ filteredEdges = filteredEdges.filter((edge) => {
+ if (childIdSet.has(edge.source_id) && childIdSet.has(edge.target_id)) {
+ internalEdges.push(formatElkEdge(edge, font, node));
+
+ return false;
+ }
+
+ return true;
+ });
+
+ return {
+ ...node,
+ childCount,
+ children: formattedChildren,
+ edges: internalEdges,
+ id: node.id,
+ isGroup: true,
+ isOpen,
+ label: node.label,
+ layoutOptions: {
+ "elk.padding": "[top=80,left=15,bottom=15,right=15]",
+ ...(direction === "RIGHT" ? { "elk.portConstraints": "FIXED_SIDE" }
: {}),
+ },
+ };
+ }
+
+ if (!isOpen && node.children !== undefined) {
+ // Use a Set for O(1) membership checks — childIds.includes() would be
+ // O(n) per edge, turning the filter/map into O(n × E) for large groups.
+ filteredEdges = rewriteGroupEdges(filteredEdges, new Set(childIds),
node.id);
+ }
+
+ const label = `${node.label}${node.is_mapped ? "[1000]" :
""}${node.children ? ` + ${node.children.length} tasks` : ""}`;
+ let width = getTextWidth(label, font);
+ const hasStateBar = Boolean(node.is_mapped) || Boolean(node.children);
+ let height = hasStateBar ? 90 : 80;
+
+ if (node.type === "join") {
+ width = 10;
+ height = 10;
+ } else if (node.type === "asset-condition") {
+ width = 30;
+ height = 30;
+ }
+
+ return {
+ assetCondition: node.asset_condition_type,
+ childCount,
+ height,
+ id: node.id,
+ isGroup: Boolean(node.children),
+ isMapped: node.is_mapped === null ? undefined : node.is_mapped,
+ label: node.label,
+ layoutOptions: direction === "RIGHT" ? { "elk.portConstraints":
"FIXED_SIDE" } : undefined,
+ operator: node.operator,
+ setupTeardownType: node.setup_teardown_type,
+ tooltip: node.tooltip,
+ type: node.type,
+ width,
+ };
+ };
+
+ const children = nodes.map(formatChildNode);
+ const edges = filteredEdges.map((fe) => formatElkEdge(fe, font));
+
+ return {
+ children: children as Array<ElkNode>,
+ edges,
+ id: "root",
+ layoutOptions: {
+ "elk.core.options.EdgeLabelPlacement": "CENTER",
+ "elk.direction": direction,
+ // SIMPLE placement is a single-pass algorithm — much faster than the
+ // default BRANDES_KOEPF four-pass approach with acceptable quality for
DAGs.
+ "elk.layered.nodePlacement.strategy": "SIMPLE",
+ // Crossing minimisation thoroughness (default 7) controls how many
random
+ // sweeps are attempted. Drop to 3 for large graphs where the extra
passes
+ // rarely pay off and add noticeable layout latency.
+ ...(filteredEdges.length > 100 ? { "elk.layered.thoroughness": "3" } :
{}),
+ // hierarchyHandling is only needed when open groups create
cross-hierarchy
+ // edges. For flat DAGs (no open groups) omitting it simplifies ELK's
task.
+ ...(Boolean(openGroupIds?.length) ? { hierarchyHandling:
"INCLUDE_CHILDREN" } : {}),
+ "spacing.edgeLabel": "10.0",
+ },
+ };
+};
diff --git a/airflow-core/src/airflow/ui/src/components/Graph/reactflowUtils.ts
b/airflow-core/src/airflow/ui/src/components/Graph/reactflowUtils.ts
index d73eec056ee..9c6dcbdf133 100644
--- a/airflow-core/src/airflow/ui/src/components/Graph/reactflowUtils.ts
+++ b/airflow-core/src/airflow/ui/src/components/Graph/reactflowUtils.ts
@@ -50,7 +50,9 @@ type FlattenNodesProps = {
parent?: NodeType;
};
-// Generate a flattened list of nodes for react-flow to render
+// Generate a flattened list of nodes for react-flow to render.
+// Uses push() throughout to avoid the O(n²) spread-in-loop pattern that the
+// previous implementation had (edges = [...edges, ...newEdges] per node).
export const flattenGraph = ({
children,
level = 0,
@@ -59,8 +61,8 @@ export const flattenGraph = ({
edges: Array<ElkExtendedEdge>;
nodes: Array<NodeType>;
} => {
- let nodes: Array<NodeType> = [];
- let edges: Array<ElkExtendedEdge> = [];
+ const nodes: Array<NodeType> = [];
+ const edges: Array<ElkExtendedEdge> = [];
if (!children) {
return { edges, nodes };
@@ -74,18 +76,16 @@ export const flattenGraph = ({
data: { ...node, depth: level },
height: node.height,
id: node.id,
- position: {
- x,
- y,
- },
+ position: { x, y },
type: node.type,
width: node.width,
...parentNode,
} satisfies NodeType;
- edges = [
- ...edges,
- ...(node.edges ?? []).map((edge) => ({
+ nodes.push(newNode);
+
+ for (const edge of node.edges ?? []) {
+ edges.push({
...edge,
labels: edge.labels?.map((label) => ({
...label,
@@ -94,24 +94,12 @@ export const flattenGraph = ({
})),
sections: edge.sections?.map((section) => ({
...section,
- // eslint-disable-next-line max-nested-callbacks
- bendPoints: section.bendPoints?.map((bp) => ({
- x: bp.x + x,
- y: bp.y + y,
- })),
- endPoint: {
- x: section.endPoint.x + x,
- y: section.endPoint.y + y,
- },
- startPoint: {
- x: section.startPoint.x + x,
- y: section.startPoint.y + y,
- },
+ bendPoints: section.bendPoints?.map((bp) => ({ x: bp.x + x, y: bp.y
+ y })),
+ endPoint: { x: section.endPoint.x + x, y: section.endPoint.y + y },
+ startPoint: { x: section.startPoint.x + x, y: section.startPoint.y +
y },
})),
- })),
- ];
-
- nodes.push(newNode);
+ });
+ }
if (node.children) {
const { edges: childEdges, nodes: childNodes } = flattenGraph({
@@ -120,15 +108,12 @@ export const flattenGraph = ({
parent: newNode,
});
- nodes = [...nodes, ...childNodes];
- edges = [...edges, ...childEdges];
+ nodes.push(...childNodes);
+ edges.push(...childEdges);
}
});
- return {
- edges,
- nodes,
- };
+ return { edges, nodes };
};
type Edge = {
diff --git a/airflow-core/src/airflow/ui/src/components/Graph/useGraphLayout.ts
b/airflow-core/src/airflow/ui/src/components/Graph/useGraphLayout.ts
index a340ddd47a6..9ffeb0fb673 100644
--- a/airflow-core/src/airflow/ui/src/components/Graph/useGraphLayout.ts
+++ b/airflow-core/src/airflow/ui/src/components/Graph/useGraphLayout.ts
@@ -18,13 +18,31 @@
*/
import { createListCollection } from "@chakra-ui/react";
import { useQuery } from "@tanstack/react-query";
-import ELK, { type ElkNode, type ElkExtendedEdge, type ElkShape } from "elkjs";
+import ELK, { type ElkNode } from "elkjs";
+// ?raw imports the file content as a plain string without any transformation.
+// We create a blob: URL from it so the Worker is always same-origin to the
+// page — avoiding the cross-origin SecurityError that occurs in Airflow's dev
+// setup where Vite (5173) and Flask (28080) run on different ports, and all
+// URL-based worker approaches (?worker, ?worker&inline, new URL()) resolve to
+// the Vite origin which browsers reject for Workers.
+import ElkWorkerSource from "elkjs/lib/elk-worker.min.js?raw";
import type { TFunction } from "i18next";
-import type { EdgeResponse, NodeResponse, StructureDataResponse } from
"openapi/requests/types.gen";
+import type { NodeResponse, StructureDataResponse } from
"openapi/requests/types.gen";
+import { generateElkGraph } from "./elkGraphUtils";
import { flattenGraph, formatFlowEdges } from "./reactflowUtils";
+// Blob URL created once at module load. `type: "classic"` preserves the
+// original CJS environment detection in elk-worker: as a classic script
+// `typeof module === "undefined"`, so the worker sets self.onmessage rather
+// than exporting a FakeWorker.
+const elkWorkerBlobUrl = URL.createObjectURL(new Blob([ElkWorkerSource], {
type: "application/javascript" }));
+
+const elk = new ELK({
+ workerFactory: () => new Worker(elkWorkerBlobUrl, { type: "classic" }),
+});
+
export type Direction = "DOWN" | "LEFT" | "RIGHT" | "UP";
export const directionOptions = (translate: TFunction) =>
createListCollection({
@@ -36,212 +54,8 @@ export const directionOptions = (translate: TFunction) =>
],
});
-type EdgeLabel = {
- height: number;
- id: string;
- text: string;
- width: number;
-};
-
-type FormattedNode = {
- assetCondition?: NodeResponse["asset_condition_type"];
- childCount?: number;
- edges?: Array<FormattedEdge>;
- isGroup: boolean;
- isMapped?: boolean;
- isOpen?: boolean;
- setupTeardownType?: NodeResponse["setup_teardown_type"];
-} & ElkShape &
- NodeResponse;
-
-type FormattedEdge = {
- id: string;
- isSetupTeardown?: boolean;
- labels?: Array<EdgeLabel>;
- parentNode?: string;
-} & ElkExtendedEdge;
-
export type LayoutNode = ElkNode & NodeResponse;
-// Take text and font to calculate how long each node should be
-const getTextWidth = (text: string, font: string) => {
- const context = document.createElement("canvas").getContext("2d");
-
- if (context) {
- context.font = font;
- const metrics = context.measureText(text);
-
- return metrics.width > 200 ? metrics.width : 200;
- }
-
- const length = text.length * 9;
-
- return length > 200 ? length : 200;
-};
-
-const formatElkEdge = (edge: EdgeResponse, font: string, node?: NodeResponse):
FormattedEdge => ({
- id: `${edge.source_id}-${edge.target_id}`,
- isSetupTeardown: edge.is_setup_teardown === null ? undefined :
edge.is_setup_teardown,
- // isSourceAsset: e.isSourceAsset,
- labels:
- edge.label === undefined || edge.label === null
- ? []
- : [
- {
- height: 16,
- id: edge.label,
- text: edge.label,
- width: getTextWidth(edge.label, font),
- },
- ],
- parentNode: node?.id,
- sources: [edge.source_id],
- targets: [edge.target_id],
-});
-
-const getNestedChildIds = (children: Array<NodeResponse>) => {
- let childIds: Array<string> = [];
-
- children.forEach((child) => {
- childIds.push(child.id);
- if (child.children) {
- const nestedChildIds = getNestedChildIds(child.children);
-
- childIds = [...childIds, ...nestedChildIds];
- }
- });
-
- return childIds;
-};
-
-type GenerateElkProps = {
- direction: Direction;
- edges: Array<EdgeResponse>;
- font: string;
- nodes: Array<NodeResponse>;
- openGroupIds?: Array<string>;
-};
-
-const generateElkGraph = ({
- direction,
- edges: unformattedEdges,
- font,
- nodes,
- openGroupIds,
-}: GenerateElkProps): ElkNode => {
- const closedGroupIds: Array<string> = [];
- let filteredEdges = unformattedEdges;
-
- const formatChildNode = (node: NodeResponse): FormattedNode => {
- const isOpen = openGroupIds?.includes(node.id);
-
- const childCount = node.children?.filter((child) => child.type !==
"join").length ?? 0;
- const childIds =
- node.children === null || node.children === undefined ? [] :
getNestedChildIds(node.children);
-
- if (isOpen && node.children !== null && node.children !== undefined) {
- return {
- ...node,
- childCount,
- children: node.children.map(formatChildNode),
- edges: filteredEdges
- .filter((edge) => {
- if (childIds.includes(edge.source_id) &&
childIds.includes(edge.target_id)) {
- // Remove edge from array when we add it here
- filteredEdges = filteredEdges.filter(
- (fe) => !(fe.source_id === edge.source_id && fe.target_id ===
edge.target_id),
- );
-
- return true;
- }
-
- return false;
- })
- .map((edge) => formatElkEdge(edge, font, node)),
- id: node.id,
- isGroup: true,
- isOpen,
- label: node.label,
- layoutOptions: {
- "elk.padding": "[top=80,left=15,bottom=15,right=15]",
- ...(direction === "RIGHT" ? { "elk.portConstraints": "FIXED_SIDE" }
: {}),
- },
- };
- }
-
- if (!Boolean(isOpen) && node.children !== undefined) {
- const seenEdges = new Set<string>();
-
- filteredEdges = filteredEdges
- // Filter out internal group edges
- .filter((fe) => !(childIds.includes(fe.source_id) &&
childIds.includes(fe.target_id)))
- // For external group edges, point to the group itself instead of a
child node
- .map((fe) => ({
- ...fe,
- source_id: childIds.includes(fe.source_id) ? node.id : fe.source_id,
- target_id: childIds.includes(fe.target_id) ? node.id : fe.target_id,
- }))
- // Deduplicate edges based on source_id and target_id composite
- .filter((fe) => {
- const edgeKey = `${fe.source_id}-${fe.target_id}`;
-
- if (seenEdges.has(edgeKey)) {
- return false;
- }
- seenEdges.add(edgeKey);
-
- return true;
- });
- closedGroupIds.push(node.id);
- }
-
- const label = `${node.label}${node.is_mapped ? "[1000]" :
""}${node.children ? ` + ${node.children.length} tasks` : ""}`;
- let width = getTextWidth(label, font);
- const hasStateBar = Boolean(node.is_mapped) || Boolean(node.children);
- let height = hasStateBar ? 90 : 80;
-
- if (node.type === "join") {
- width = 10;
- height = 10;
- } else if (node.type === "asset-condition") {
- width = 30;
- height = 30;
- }
-
- return {
- assetCondition: node.asset_condition_type,
- childCount,
- height,
- id: node.id,
- isGroup: Boolean(node.children),
- isMapped: node.is_mapped === null ? undefined : node.is_mapped,
- label: node.label,
- layoutOptions: direction === "RIGHT" ? { "elk.portConstraints":
"FIXED_SIDE" } : undefined,
- operator: node.operator,
- setupTeardownType: node.setup_teardown_type,
- tooltip: node.tooltip,
- type: node.type,
- width,
- };
- };
-
- const children = nodes.map(formatChildNode);
-
- const edges = filteredEdges.map((fe) => formatElkEdge(fe, font));
-
- return {
- children: children as Array<ElkNode>,
- edges,
- id: "root",
- layoutOptions: {
- "elk.core.options.EdgeLabelPlacement": "CENTER",
- "elk.direction": direction,
- hierarchyHandling: "INCLUDE_CHILDREN",
- "spacing.edgeLabel": "10.0",
- },
- };
-};
-
type LayoutProps = {
direction: Direction;
openGroupIds: Array<string>;
@@ -258,7 +72,6 @@ export const useGraphLayout = ({
useQuery({
queryFn: async () => {
const font = `bold 18px
${globalThis.getComputedStyle(document.body).fontFamily}`;
- const elk = new ELK();
// 1. Format graph data to pass for elk to process
const graph = generateElkGraph({
@@ -277,10 +90,18 @@ export const useGraphLayout = ({
children: (data.children ?? []) as Array<LayoutNode>,
});
- // merge & dedupe edges
- const flatEdges = [...(data.edges ?? []), ...flattenedData.edges].filter(
- (value, index, self) => index === self.findIndex((edge) => edge.id ===
value.id),
- );
+ // merge & dedupe edges — O(n) via Map (first occurrence wins) rather
than
+ // O(n²) findIndex. Root-level edges from ELK come first; child edges
from
+ // flattenedData are skipped when the same id is already present.
+ const seenEdgeIds = new Set<string>();
+ const flatEdges = [...(data.edges ?? []),
...flattenedData.edges].filter((edge) => {
+ if (seenEdgeIds.has(edge.id)) {
+ return false;
+ }
+ seenEdgeIds.add(edge.id);
+
+ return true;
+ });
const formattedEdges = formatFlowEdges({ edges: flatEdges });
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/Graph/Graph.tsx
b/airflow-core/src/airflow/ui/src/layouts/Details/Graph/Graph.tsx
index fb16cf04c84..6ceeb9bacdf 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/Graph/Graph.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/Graph/Graph.tsx
@@ -16,10 +16,20 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { useToken } from "@chakra-ui/react";
-import { ReactFlow, Controls, Background, MiniMap, type Node as ReactFlowNode
} from "@xyflow/react";
+import { Box, Spinner, useToken } from "@chakra-ui/react";
+import {
+ ReactFlow,
+ Controls,
+ ControlButton,
+ Background,
+ MiniMap,
+ useReactFlow,
+ type Node as ReactFlowNode,
+} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { useEffect } from "react";
+import { useTranslation } from "react-i18next";
+import { MdCenterFocusStrong } from "react-icons/md";
import { useParams, useSearchParams } from "react-router-dom";
import { useLocalStorage } from "usehooks-ts";
@@ -37,6 +47,47 @@ import { useDependencyGraph } from
"src/queries/useDependencyGraph";
import { useGridTiSummariesStream } from "src/queries/useGridTISummaries.ts";
import { getReactFlowThemeStyle } from "src/theme";
+// Hoisted to module scope so ReactFlow receives a stable reference and skips
+// its internal shallow-equality check on every render.
+const defaultEdgeOptions = { zIndex: 1 };
+
+// Fits the viewport whenever a new layout is committed. Must live inside
+// <ReactFlow> to call useReactFlow(). Using layoutData as the dep means it
+// only fires when ELK produces a new layout, not on task-instance updates or
+// selection changes (unlike the `fitView` prop, which runs on every re-mount).
+const FitViewOnLayout = ({ layoutData }: { readonly layoutData: object |
undefined }) => {
+ const { fitView } = useReactFlow();
+
+ useEffect(() => {
+ if (layoutData !== undefined) {
+ void fitView({ padding: 0.1 });
+ }
+ }, [fitView, layoutData]);
+
+ return null;
+};
+
+const GraphControls = ({ selectedNodeId }: { readonly selectedNodeId?: string
}) => {
+ const { t: translate } = useTranslation("components");
+ const { fitView } = useReactFlow();
+
+ return (
+ <Controls showInteractive={false}>
+ {selectedNodeId === undefined ? undefined : (
+ <ControlButton
+ aria-label={translate("graph.zoomToTask")}
+ onClick={() => {
+ void fitView({ duration: 500, nodes: [{ id: selectedNodeId }],
padding: 0.5 });
+ }}
+ title={translate("graph.zoomToTask")}
+ >
+ <MdCenterFocusStrong />
+ </ControlButton>
+ )}
+ </Controls>
+ );
+};
+
const nodeColor = (
{ data: { depth, height, isOpen, taskInstance, width }, type }:
ReactFlowNode<CustomNodeProps>,
evenColor?: string,
@@ -122,15 +173,17 @@ export const Graph = () => {
const dagDepEdges = dependencies === "all" ? dagDependencies.edges : [];
const dagDepNodes = dependencies === "all" ? dagDependencies.nodes : [];
- const { data } = useGraphLayout({
+ const layoutEdges = [...graphData.edges, ...dagDepEdges];
+ const layoutNodes = dagDepNodes.length
+ ? dagDepNodes.map((node) => (node.id === `dag:${dagId}` ? { ...node,
children: graphData.nodes } : node))
+ : graphData.nodes;
+ const layoutOpenGroupIds = [...openGroupIds, ...(dependencies === "all" ?
[`dag:${dagId}`] : [])];
+
+ const { data, isPending } = useGraphLayout({
direction,
- edges: [...graphData.edges, ...dagDepEdges],
- nodes: dagDepNodes.length
- ? dagDepNodes.map((node) =>
- node.id === `dag:${dagId}` ? { ...node, children: graphData.nodes }
: node,
- )
- : graphData.nodes,
- openGroupIds: [...openGroupIds, ...(dependencies === "all" ?
[`dag:${dagId}`] : [])],
+ edges: layoutEdges,
+ nodes: layoutNodes,
+ openGroupIds: layoutOpenGroupIds,
versionNumber: selectedVersion,
});
@@ -145,7 +198,7 @@ export const Graph = () => {
});
const gridTISummaries = runId ? summariesByRunId.get(runId) : undefined;
- // Add task instances to the node data but without having to recalculate how
the graph is laid out
+ // Add task instances and selection state to node data without recalculating
layout.
const nodes = data?.nodes.map((node) => {
const taskInstance = gridTISummaries?.task_instances.find((ti) =>
ti.task_id === node.id);
@@ -159,58 +212,83 @@ export const Graph = () => {
};
});
+ // isSelected is intentionally absent here — Edge.tsx reads it directly from
+ // the node store via useNodesData, so the edges array stays stable when only
+ // the selected task changes, avoiding a full edge reconciliation pass.
const edges = (data?.edges ?? []).map((edge) => ({
...edge,
data: {
...edge.data,
rest: {
...edge.data?.rest,
- isSelected:
- taskId === edge.source ||
- taskId === edge.target ||
- groupId === edge.source ||
- groupId === edge.target ||
- edge.source === `dag:${dagId}` ||
- edge.target === `dag:${dagId}`,
},
},
}));
+ const selectedNodeId = taskId ?? groupId;
+
return (
- <ReactFlow
- colorMode={colorMode}
- defaultEdgeOptions={{ zIndex: 1 }}
- edges={edges}
- edgeTypes={edgeTypes}
- // Fit view to selected task or the whole graph on render
- fitView
- maxZoom={1.5}
- minZoom={0.25}
- nodes={nodes}
- nodesDraggable={false}
- nodeTypes={nodeTypes}
- onlyRenderVisibleElements
- style={getReactFlowThemeStyle(colorMode)}
- >
- <Background />
- <Controls showInteractive={false} />
- <MiniMap
- nodeColor={(node: ReactFlowNode<CustomNodeProps>) =>
- nodeColor(
- node,
- colorMode === "dark" ? evenDark : evenLight,
- colorMode === "dark" ? oddDark : oddLight,
- )
- }
- nodeStrokeColor={(node: ReactFlowNode<CustomNodeProps>) =>
- node.data.isSelected && selectedColor !== undefined ? selectedColor
: ""
- }
- nodeStrokeWidth={15}
- pannable
- style={{ height: 150, width: 200 }}
- zoomable
- />
- <DownloadButton name={dagId} />
- </ReactFlow>
+ <Box height="100%" position="relative" width="100%">
+ {isPending ? (
+ <Box
+ alignItems="center"
+ display="flex"
+ height="100%"
+ justifyContent="center"
+ left={0}
+ position="absolute"
+ top={0}
+ width="100%"
+ zIndex={10}
+ >
+ <Spinner color="blue.500" size="xl" />
+ </Box>
+ ) : undefined}
+ <ReactFlow
+ colorMode={colorMode}
+ defaultEdgeOptions={defaultEdgeOptions}
+ edges={edges}
+ edgesFocusable={false}
+ edgeTypes={edgeTypes}
+ maxZoom={1.5}
+ minZoom={0.01}
+ nodes={nodes}
+ nodesConnectable={false}
+ nodesDraggable={false}
+ nodesFocusable={false}
+ nodeTypes={nodeTypes}
+ onlyRenderVisibleElements
+ style={getReactFlowThemeStyle(colorMode)}
+ >
+ <Background />
+ {/* Fit the viewport after each new ELK layout instead of using the
+ fitView prop, which re-fires on every re-mount even when nodes are
+ served from the React Query cache. */}
+ <FitViewOnLayout layoutData={data} />
+ <GraphControls selectedNodeId={selectedNodeId} />
+ {/* Hide the MiniMap for large graphs — it processes all nodes even
when
+ onlyRenderVisibleElements is set, adding meaningful paint cost with
+ little benefit at 500+ nodes where the map is a near-solid blob.
*/}
+ {data !== undefined && data.nodes.length <= 500 ? (
+ <MiniMap
+ nodeColor={(node: ReactFlowNode<CustomNodeProps>) =>
+ nodeColor(
+ node,
+ colorMode === "dark" ? evenDark : evenLight,
+ colorMode === "dark" ? oddDark : oddLight,
+ )
+ }
+ nodeStrokeColor={(node: ReactFlowNode<CustomNodeProps>) =>
+ node.data.isSelected && selectedColor !== undefined ?
selectedColor : ""
+ }
+ nodeStrokeWidth={15}
+ pannable
+ style={{ height: 150, width: 200 }}
+ zoomable
+ />
+ ) : undefined}
+ <DownloadButton name={dagId} />
+ </ReactFlow>
+ </Box>
);
};
diff --git a/airflow-core/src/airflow/ui/testsSetup.ts
b/airflow-core/src/airflow/ui/testsSetup.ts
index 91d3533b0aa..1bb26b7d5ba 100644
--- a/airflow-core/src/airflow/ui/testsSetup.ts
+++ b/airflow-core/src/airflow/ui/testsSetup.ts
@@ -24,6 +24,19 @@ import { beforeEach, beforeAll, afterAll, afterEach, vi }
from "vitest";
import { handlers } from "src/mocks/handlers";
+// ELK creates a Worker at module-load time, which is not available in
+// happy-dom. Mock useGraphLayout so the Worker is never constructed. Any
+// test that specifically exercises graph layout should override this mock.
+vi.mock("src/components/Graph/useGraphLayout", () => ({
+ directionOptions: () => ({ items: [] }),
+ useGraphLayout: vi.fn().mockReturnValue({ data: undefined, isPending: false
}),
+}));
+
+// Render nothing for the two graph-rendering components so tests that render
+// full pages don't need to stub layout data.
+vi.mock("src/layouts/Details/Graph/Graph", () => ({ Graph: () => null }));
+vi.mock("src/pages/Asset/AssetGraph", () => ({ AssetGraph: () => null }));
+
// Mock Chart.js to prevent DOM access errors during test cleanup
vi.mock("react-chartjs-2", () => ({
Bar: vi.fn(() => {