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

pierrejeambrun 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 0fa7da1d2e4 [v3-2-test] UI: Fix Graph layout for TaskGroup tasks wired 
to external nodes (#67720) (#67830)
0fa7da1d2e4 is described below

commit 0fa7da1d2e4c35092a218738d9378ecfeeb6cb8e
Author: github-actions[bot] 
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Mon Jun 1 14:55:28 2026 +0200

    [v3-2-test] UI: Fix Graph layout for TaskGroup tasks wired to external 
nodes (#67720) (#67830)
    
    Open ``@task_group`` rendered with vertically-stacked internals and edges
    crossing the boundary whenever an internal task had a direct dependency
    on a node outside the group (an "escape edge" that bypassed the group's
    entry/exit interface). Dag execution was unaffected.
    
    Two underlying issues, both in the ELK graph-layout refactor from #65031:
    
    1. ``hasUniformExternalConnectivity`` was too lenient — it fired whenever
       externally-connected children separately shared the same external
       sources OR the same external targets, instead of the canonical
       fan-in/fan-out pattern where every child has the same full
       ``(sources, targets)`` profile. On mixed-profile groups (entry +
       exits), it incorrectly fired and collapsed the author's deliberately-
       wired escape edges into a single group-level edge, hiding the intent.
    
    2. When the optimisation did fire on an open group, ``rewriteGroupEdges``
       was tuned for closed groups and dropped the group's internal edges
       too, leaving ELK with no internal-layout information for the children
       (the visible symptom in #67714).
    
    Fix: tighten ``hasUniformExternalConnectivity`` to require the full
    profile to match across externally-connected children, and add a
    ``preserveInternal`` option to ``rewriteGroupEdges`` so the canonical
    fan-in/fan-out path keeps internals intact.
    (cherry picked from commit 55780f2d3cd7db64bc6d1479b9da7bff765f046d)
    
    
    Closes: #67714
    
    Co-authored-by: Rahul Vats <[email protected]>
---
 .../ui/src/components/Graph/elkGraphUtils.test.ts  | 305 +++++++++++++++++++++
 .../ui/src/components/Graph/elkGraphUtils.ts       | 181 +++++++-----
 2 files changed, 425 insertions(+), 61 deletions(-)

diff --git 
a/airflow-core/src/airflow/ui/src/components/Graph/elkGraphUtils.test.ts 
b/airflow-core/src/airflow/ui/src/components/Graph/elkGraphUtils.test.ts
new file mode 100644
index 00000000000..02942fef9e4
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/components/Graph/elkGraphUtils.test.ts
@@ -0,0 +1,305 @@
+/*!
+ * 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 { describe, expect, it } from "vitest";
+
+import type { EdgeResponse, NodeResponse } from "openapi/requests/types.gen";
+
+import { generateElkGraph, hasUniformExternalConnectivity } from 
"./elkGraphUtils";
+import type { FormattedNode } from "./elkGraphUtils";
+
+// Minimal NodeResponse builder — fills the fields the layout pipeline 
actually reads.
+const buildNode = (overrides: Partial<NodeResponse> & Pick<NodeResponse, "id" 
| "label">): NodeResponse => ({
+  asset_condition_type: null,
+  children: null,
+  is_mapped: null,
+  operator: null,
+  setup_teardown_type: null,
+  tooltip: null,
+  type: "task",
+  ...overrides,
+});
+
+const buildEdge = (sourceId: string, targetId: string): EdgeResponse => ({
+  is_setup_teardown: null,
+  label: null,
+  source_id: sourceId,
+  target_id: targetId,
+});
+
+describe("hasUniformExternalConnectivity", () => {
+  it("returns false for the vanilla TaskGroup shape (only entry/exit cross the 
boundary)", () => {
+    // ``a1`` has external source {start}; ``group_done`` has external target
+    // {final_task}. Their profiles differ — not canonical fan-in/fan-out.
+    const edges = [buildEdge("start", "a1"), buildEdge("group_done", 
"final_task")];
+    const result = hasUniformExternalConnectivity(new Set(["a1", "branch", 
"a2", "a3", "group_done"]), edges);
+
+    expect(result).toBe(false);
+  });
+
+  it("returns false when externally-connected children have mixed profiles 
(entry + exits)", () => {
+    // The #67714 bug-trigger shape: a1 is the "entry" with external source
+    // {start}, while a2/a3/group_done are "exits" with external target
+    // {final_task}. Profiles differ → not canonical → return false so the
+    // author's explicit escape edges remain visible.
+    const edges = [
+      buildEdge("start", "a1"),
+      buildEdge("group_done", "final_task"),
+      buildEdge("a2", "final_task"),
+      buildEdge("a3", "final_task"),
+    ];
+    const result = hasUniformExternalConnectivity(new Set(["a1", "branch", 
"a2", "a3", "group_done"]), edges);
+
+    expect(result).toBe(false);
+  });
+
+  it("returns true for the canonical fan-in/fan-out shape", () => {
+    // Every child has the same external source AND the same external target —
+    // the "cleanup group" pattern that the optimisation is designed for.
+    const edges = [
+      buildEdge("upstream", "T1"),
+      buildEdge("upstream", "T2"),
+      buildEdge("upstream", "T3"),
+      buildEdge("T1", "downstream"),
+      buildEdge("T2", "downstream"),
+      buildEdge("T3", "downstream"),
+    ];
+    const result = hasUniformExternalConnectivity(new Set(["T1", "T2", "T3"]), 
edges);
+
+    expect(result).toBe(true);
+  });
+});
+
+describe("generateElkGraph — open TaskGroup with escape edges (#67714)", () => 
{
+  // Mirrors the minimal reproducer from issue #67714:
+  //
+  //   start ─→ group_a { a1 ─→ branch ─→ [a2, a3] ─→ group_done } ─→ 
final_task
+  //                                       ↘──── ↘────────┐
+  //                                              ───────→ final_task
+  //
+  // ``a1`` is the entry (external source {start}), 
``a2``/``a3``/``group_done``
+  // are exits (external target {final_task}). Their profiles differ, so
+  // ``hasUniformExternalConnectivity`` correctly returns false and the
+  // open-group rewrite branch never runs — every internal and escape edge
+  // is rendered individually.
+  const internalChildren: Array<NodeResponse> = [
+    buildNode({ id: "a1", label: "task_a1" }),
+    buildNode({ id: "branch", label: "branch_a" }),
+    buildNode({ id: "a2", label: "task_a2" }),
+    buildNode({ id: "a3", label: "task_a3" }),
+    buildNode({ id: "group_done", label: "group_done" }),
+  ];
+
+  const nodes: Array<NodeResponse> = [
+    buildNode({ id: "start", label: "start" }),
+    buildNode({
+      children: internalChildren,
+      id: "group_a",
+      label: "group_a",
+    }),
+    buildNode({ id: "final_task", label: "final_task" }),
+  ];
+
+  const edges: Array<EdgeResponse> = [
+    buildEdge("start", "a1"),
+    buildEdge("a1", "branch"),
+    buildEdge("branch", "a2"),
+    buildEdge("branch", "a3"),
+    buildEdge("a2", "group_done"),
+    buildEdge("a3", "group_done"),
+    buildEdge("group_done", "final_task"),
+    // The two "escape" edges that trip the bug:
+    buildEdge("a2", "final_task"),
+    buildEdge("a3", "final_task"),
+  ];
+
+  it("keeps internal group edges so ELK can lay out the children", () => {
+    const root = generateElkGraph({
+      direction: "RIGHT",
+      edges,
+      font: "12px sans-serif",
+      nodes,
+      openGroupIds: ["group_a"],
+    });
+
+    const groupNode = (root.children as Array<FormattedNode>).find((child) => 
child.id === "group_a");
+
+    expect(groupNode).toBeDefined();
+    expect(groupNode?.isOpen).toBe(true);
+
+    // All five internal edges must survive — that's what ELK needs to lay out
+    // a1 → branch → [a2, a3] → group_done correctly inside the group.
+    const internalEdgeIds = new Set(groupNode?.edges?.map((edge) => edge.id) 
?? []);
+
+    expect(internalEdgeIds).toEqual(
+      new Set(["a1-branch", "branch-a2", "branch-a3", "a2-group_done", 
"a3-group_done"]),
+    );
+  });
+
+  it("renders each crossing escape edge individually instead of collapsing 
them", () => {
+    const root = generateElkGraph({
+      direction: "RIGHT",
+      edges,
+      font: "12px sans-serif",
+      nodes,
+      openGroupIds: ["group_a"],
+    });
+
+    // The deliberately-wired escape edges from a2 and a3 must remain visible
+    // so the author's explicit dependency intent is preserved in the graph.
+    const rootEdgeIds = new Set(root.edges?.map((edge) => edge.id) ?? []);
+
+    expect(rootEdgeIds).toEqual(
+      new Set(["start-a1", "group_done-final_task", "a2-final_task", 
"a3-final_task"]),
+    );
+  });
+});
+
+describe("generateElkGraph — open TaskGroup matching the canonical 
fan-in/fan-out shape", () => {
+  // The "cleanup group" pattern the optimisation is designed for: every child
+  // has the SAME external source AND the SAME external target. The collapse
+  // optimisation should still fire here, and ``preserveInternal: true`` must
+  // keep any internal edges intact.
+  it("collapses crossing edges to a single group-level edge while keeping 
internal edges", () => {
+    const nodes: Array<NodeResponse> = [
+      buildNode({ id: "upstream", label: "upstream" }),
+      buildNode({
+        children: [
+          buildNode({ id: "T1", label: "T1" }),
+          buildNode({ id: "T2", label: "T2" }),
+          buildNode({ id: "T3", label: "T3" }),
+          buildNode({ id: "T_internal", label: "T_internal" }),
+        ],
+        id: "cleanup_group",
+        label: "cleanup_group",
+      }),
+      buildNode({ id: "downstream", label: "downstream" }),
+    ];
+
+    const edges: Array<EdgeResponse> = [
+      buildEdge("upstream", "T1"),
+      buildEdge("upstream", "T2"),
+      buildEdge("upstream", "T3"),
+      buildEdge("T1", "downstream"),
+      buildEdge("T2", "downstream"),
+      buildEdge("T3", "downstream"),
+      // An internal edge within the group; must survive the optimisation so
+      // ELK can lay it out inside the open group.
+      buildEdge("T1", "T_internal"),
+    ];
+
+    const root = generateElkGraph({
+      direction: "RIGHT",
+      edges,
+      font: "12px sans-serif",
+      nodes,
+      openGroupIds: ["cleanup_group"],
+    });
+
+    const groupNode = (root.children as Array<FormattedNode>).find((child) => 
child.id === "cleanup_group");
+
+    // The internal T1 → T_internal edge must still be present in the group's
+    // edges array (this is what the ``preserveInternal: true`` flag protects).
+    const internalEdgeIds = new Set(groupNode?.edges?.map((edge) => edge.id) 
?? []);
+
+    expect(internalEdgeIds).toEqual(new Set(["T1-T_internal"]));
+
+    // Six crossing edges (3 fan-in + 3 fan-out) collapse to one each.
+    const rootEdgeIds = new Set(root.edges?.map((edge) => edge.id) ?? []);
+
+    expect(rootEdgeIds).toEqual(new Set(["upstream-cleanup_group", 
"cleanup_group-downstream"]));
+  });
+});
+
+describe("generateElkGraph — open TaskGroup without escape edges", () => {
+  // Regression guard for the simple TaskGroup shape (only entry/exit cross the
+  // boundary). ``hasUniformExternalConnectivity`` returns false here, so the
+  // open-group rewrite branch never runs — internal edges should already 
survive.
+  it("keeps internal group edges intact", () => {
+    const nodes: Array<NodeResponse> = [
+      buildNode({ id: "start", label: "start" }),
+      buildNode({
+        children: [
+          buildNode({ id: "a1", label: "task_a1" }),
+          buildNode({ id: "a2", label: "task_a2" }),
+          buildNode({ id: "group_done", label: "group_done" }),
+        ],
+        id: "group_a",
+        label: "group_a",
+      }),
+      buildNode({ id: "final_task", label: "final_task" }),
+    ];
+
+    const edges: Array<EdgeResponse> = [
+      buildEdge("start", "a1"),
+      buildEdge("a1", "a2"),
+      buildEdge("a2", "group_done"),
+      buildEdge("group_done", "final_task"),
+    ];
+
+    const root = generateElkGraph({
+      direction: "RIGHT",
+      edges,
+      font: "12px sans-serif",
+      nodes,
+      openGroupIds: ["group_a"],
+    });
+
+    const groupNode = (root.children as Array<FormattedNode>).find((child) => 
child.id === "group_a");
+
+    const internalEdgeIds = new Set(groupNode?.edges?.map((edge) => edge.id) 
?? []);
+
+    expect(internalEdgeIds).toEqual(new Set(["a1-a2", "a2-group_done"]));
+  });
+});
+
+describe("generateElkGraph — closed TaskGroup", () => {
+  // Closed-group behaviour must still drop internal edges (they're not laid 
out
+  // when the group is collapsed) and rewrite crossings to point at the group.
+  it("drops internal edges and rewrites crossings to the group", () => {
+    const nodes: Array<NodeResponse> = [
+      buildNode({ id: "start", label: "start" }),
+      buildNode({
+        children: [buildNode({ id: "a1", label: "task_a1" }), buildNode({ id: 
"a2", label: "task_a2" })],
+        id: "group_a",
+        label: "group_a",
+      }),
+      buildNode({ id: "final_task", label: "final_task" }),
+    ];
+
+    const edges: Array<EdgeResponse> = [
+      buildEdge("start", "a1"),
+      buildEdge("a1", "a2"),
+      buildEdge("a2", "final_task"),
+    ];
+
+    const root = generateElkGraph({
+      direction: "RIGHT",
+      edges,
+      font: "12px sans-serif",
+      nodes,
+      openGroupIds: [],
+    });
+
+    const rootEdgeIds = new Set(root.edges?.map((edge) => edge.id) ?? []);
+
+    // Internal a1 → a2 must not appear at the root level; crossings collapse 
to
+    // single group-level edges.
+    expect(rootEdgeIds).toEqual(new Set(["start-group_a", 
"group_a-final_task"]));
+  });
+});
diff --git a/airflow-core/src/airflow/ui/src/components/Graph/elkGraphUtils.ts 
b/airflow-core/src/airflow/ui/src/components/Graph/elkGraphUtils.ts
index 0ab685cf37e..38fcbd91650 100644
--- a/airflow-core/src/airflow/ui/src/components/Graph/elkGraphUtils.ts
+++ b/airflow-core/src/airflow/ui/src/components/Graph/elkGraphUtils.ts
@@ -101,21 +101,30 @@ const formatElkEdge = (edge: EdgeResponse, font: string, 
node?: NodeResponse): F
 });
 
 /**
- * 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.
+ * Returns true when every child task with at least one external connection
+ * shares the **exact same full external profile** — 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:
+ * Canonical pattern — 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.
+ * Here all three children have profile ``({upstream}, {downstream})`` — same
+ * sources, same targets. 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.
+ *
+ * Mixed profiles — e.g. one child is the group's "entry" (external sources
+ * only, no external targets) while others are "exits" (external targets only,
+ * no external sources) — are NOT canonical. The author has expressed
+ * deliberately different external connectivity per child, and collapsing
+ * those edges would hide that intent. The check returns false in that case,
+ * and the caller renders each crossing edge individually. See #67714.
  *
  * Uses the original, unmodified edge list so that prior sibling group
  * transformations do not affect the connectivity check.
@@ -124,89 +133,123 @@ 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 each externally-connected child, build the full ``(sources, targets)``
+  // profile in a single map (rather than tracking sources and targets in
+  // independent maps — which loses the per-child correlation).
+  const profileByChild = new Map<string, { sources: Set<string>; targets: 
Set<string> }>();
+  const getOrInitProfile = (childId: string) => {
+    let profile = profileByChild.get(childId);
+
+    if (profile === undefined) {
+      profile = { sources: new Set<string>(), targets: new Set<string>() };
+      profileByChild.set(childId, profile);
+    }
+
+    return profile;
+  };
 
   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);
+      getOrInitProfile(edge.target_id).sources.add(edge.source_id);
     }
 
     if (sourceIsChild && !targetIsChild) {
-      const existing = targetsPerChild.get(edge.source_id) ?? new 
Set<string>();
-
-      existing.add(edge.target_id);
-      targetsPerChild.set(edge.source_id, existing);
+      getOrInitProfile(edge.source_id).targets.add(edge.target_id);
     }
   }
 
-  // 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) {
+  // Need at least 2 externally-connected children for the optimisation to be
+  // worthwhile — one child has nothing to collapse against.
+  if (profileByChild.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>();
+  // All externally-connected children must share the exact same profile.
+  const [reference, ...rest] = [...profileByChild.values()];
 
-  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);
-    }
+  // The early-return above on ``profileByChild.size < 2`` guarantees that the
+  // destructure produced a defined ``reference``, but TypeScript can't see
+  // through the map-size guard. This explicit check both narrows the type and
+  // documents the invariant.
+  if (reference === undefined) {
+    return false;
   }
 
-  // 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) {
+  const setsEqual = (left: Set<string>, right: Set<string>) => {
+    if (left.size !== right.size) {
       return false;
     }
-  }
-  for (const targets of targetsPerChild.values()) {
-    if (targets.size !== allTargets.size) {
-      return false;
+    for (const value of left) {
+      if (!right.has(value)) {
+        return false;
+      }
     }
-  }
 
-  return true;
+    return true;
+  };
+
+  return rest.every(
+    (profile) =>
+      setsEqual(profile.sources, reference.sources) && 
setsEqual(profile.targets, reference.targets),
+  );
 };
 
 // ---------------------------------------------------------------------------
 // Edge rewriting helper
 // ---------------------------------------------------------------------------
 
+type RewriteGroupEdgesProps = {
+  childIdSet: Set<string>;
+  edges: Array<EdgeResponse>;
+  groupId: string;
+  /**
+   * When false (the default, used for *closed* groups), purely-internal edges
+   * are dropped — the collapsed group does not need its internal layout.
+   *
+   * When true (used when applying the uniform-external optimisation to an
+   * *open* group), internal edges pass through unchanged so the caller can
+   * still extract them as the group's internal edges; only crossing edges get
+   * rewritten and deduplicated.
+   */
+  preserveInternal?: boolean;
+};
+
 /**
- * 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.
+ * 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 rewriteGroupEdges = ({
+  childIdSet,
+  edges,
+  groupId,
+  preserveInternal = false,
+}: RewriteGroupEdgesProps): 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) => preserveInternal || !(childIdSet.has(fe.source_id) && 
childIdSet.has(fe.target_id)))
+    .map((fe) => {
+      const sourceIsChild = childIdSet.has(fe.source_id);
+      const targetIsChild = childIdSet.has(fe.target_id);
+
+      // Internal edges of an open group must pass through unchanged so the
+      // caller can recognise and extract them. Rewriting both endpoints to
+      // ``groupId`` would (a) collapse them to a self-loop and (b) hide them
+      // from the subsequent internal-edge extraction loop.
+      if (preserveInternal && sourceIsChild && targetIsChild) {
+        return fe;
+      }
+
+      return {
+        ...fe,
+        source_id: sourceIsChild ? groupId : fe.source_id,
+        target_id: targetIsChild ? groupId : fe.target_id,
+      };
+    })
     .filter((fe) => {
       const key = `${fe.source_id}-${fe.target_id}`;
 
@@ -266,8 +309,20 @@ export const generateElkGraph = ({
       // 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.
+      //
+      // ``preserveInternal: true`` is required because the group is *open* — 
its
+      // internal edges must survive past the rewrite so the extraction loop
+      // below can pull them into the group's ``edges`` array. Without it, ELK
+      // would receive an open group with no internal edges and fail to lay out
+      // the children in a sensible left-to-right order whenever an internal
+      // task has a direct dependency on a node outside the group (see #67714).
       if (hasUniformExternalConnectivity(childIdSet, unformattedEdges)) {
-        filteredEdges = rewriteGroupEdges(filteredEdges, childIdSet, node.id);
+        filteredEdges = rewriteGroupEdges({
+          childIdSet,
+          edges: filteredEdges,
+          groupId: node.id,
+          preserveInternal: true,
+        });
       }
 
       // Extract any remaining internal edges (both endpoints inside this 
group).
@@ -302,7 +357,11 @@ export const generateElkGraph = ({
     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);
+      filteredEdges = rewriteGroupEdges({
+        childIdSet: new Set(childIds),
+        edges: filteredEdges,
+        groupId: node.id,
+      });
     }
 
     const label = `${node.label}${node.is_mapped ? "[1000]" : 
""}${node.children ? ` + ${node.children.length} tasks` : ""}`;

Reply via email to