Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions src/lib/components/CustomEdge.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,16 @@
import { BaseEdge, type EdgeProps, EdgeReconnectAnchor, getBezierPath } from '@xyflow/svelte';
import { getContext } from 'svelte';

import type { IREdge } from '../types.js';

type EdgeValidationStatus = {
hasError: boolean;
hasWarning: boolean;
isDead: boolean;
};

type EdgeById = () => Map<string, IREdge>;

let { id, sourceX, sourceY, targetX, targetY, selected }: EdgeProps = $props();

const getEdgeValidationById =
Expand All @@ -20,14 +24,27 @@
const hasWarning = $derived(!hasError && !!validationStatus?.hasWarning);
const isDead = $derived(!!validationStatus?.isDead);

// Feedback edges read the previous frame from the current output
const getEdgeById = getContext<EdgeById>('edgeById')!;
const edge = $derived(getEdgeById().get(id) ?? null);
const isFeedbackEdge = $derived(!!edge && edge.isFeedback === true);

const edgeStyle = $derived(
hasError
? 'stroke: #ef4444; stroke-width: 2px;'
: hasWarning
? 'stroke: #f59e0b; stroke-width: 2px;'
: isDead
? 'stroke: #9ca3af; stroke-width: 1.5px; stroke-dasharray: 6 4; opacity: 0.5;'
: undefined
: isFeedbackEdge
? 'stroke: #6366f1; stroke-width: 1.5px; stroke-dasharray: 4 3;'
: undefined
);

const edgeAriaLabel = $derived(
isFeedbackEdge
? 'Feedback edge: uses the previous frame from the current output, not the current frame'
: undefined
);

const [edgePath] = $derived(
Expand All @@ -52,7 +69,7 @@
</script>

{#if !reconnecting}
<BaseEdge path={edgePath} style={edgeStyle} />
<BaseEdge path={edgePath} style={edgeStyle} aria-label={edgeAriaLabel} />
{/if}

{#if selected}
Expand Down
87 changes: 87 additions & 0 deletions src/lib/components/FlowEditor.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@
setContext('nodeDefinitions', () => nodeDefinitions);
setContext('validationByNodeId', () => validationByNodeId());
setContext('edgeValidationById', () => edgeValidationById());
setContext('edgeById', () => new Map(edges.map((e: IREdge) => [e.id, e])));

let lastIssueKeys = new Set<string>();

Expand Down Expand Up @@ -265,6 +266,92 @@
!deletedNodeIds.has(edge.target)
);
};

function wouldCreateCycle(
adjacency: Map<string, string[]>,
source: string,
target: string
): boolean {
const goal = source;
const stack = [target];
/* eslint-disable-next-line svelte/prefer-svelte-reactivity */
const visited = new Set<string>();

while (stack.length > 0) {
const current = stack.pop()!;
if (current === goal) return true;
if (visited.has(current)) continue;
visited.add(current);

const neighbours = adjacency.get(current) ?? [];
for (const n of neighbours) {
if (!visited.has(n)) {
stack.push(n);
}
}
}

return false;
}

$effect(() => {
// Recompute feedback flags from scratch whenever edges change.
// Algorithm: build a DAG by marking cycle-closing edges as feedback edges.
// Existing feedback edges are ignored when detecting cycles, so cycles that already
// pass through a feedback edge are treated as intentional feedback.
// Note: Order-dependent - the last edge in insertion order that closes a cycle
// becomes the feedback edge.

if (edges.length === 0) return;

/* eslint-disable-next-line svelte/prefer-svelte-reactivity */
const adjacency = new Map<string, string[]>();
const desiredFeedbackFlags: boolean[] = [];

for (const edge of edges) {
// Feedback edges are ignored here: they break cycles but don't participate in forward traversal
if (edge.isFeedback === true) {
desiredFeedbackFlags.push(true);
continue;
}

const makesCycle = wouldCreateCycle(adjacency, edge.source, edge.target);

if (makesCycle) {
// Mark this edge as feedback to break the cycle
desiredFeedbackFlags.push(true);
} else {
desiredFeedbackFlags.push(false);
const list = adjacency.get(edge.source) ?? [];
list.push(edge.target);
adjacency.set(edge.source, list);
}
}

// Check if anything actually changed; if not, bail to avoid infinite reactive loops
let anyChanged = false;
for (let i = 0; i < edges.length; i++) {
const current = edges[i].isFeedback === true;
if (current !== desiredFeedbackFlags[i]) {
anyChanged = true;
break;
}
}
if (!anyChanged) return;

// Apply the new feedback flags immutably so Svelte change detection still works
edges = edges.map((edge: IREdge, i: number) => {
const flag = desiredFeedbackFlags[i];
if (flag === false) {
// Strip stale isFeedback if present
if (!edge.isFeedback) return edge;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { isFeedback, ...rest } = edge;
return rest as IREdge;
}
return { ...edge, isFeedback: true };
});
});
</script>

<div class="flow-editor">
Expand Down
Loading