diff --git a/src/components/CustomConnectionLine.test.tsx b/src/components/CustomConnectionLine.test.tsx
index c7b5a989..dc8d77a9 100644
--- a/src/components/CustomConnectionLine.test.tsx
+++ b/src/components/CustomConnectionLine.test.tsx
@@ -49,4 +49,44 @@ describe('CustomConnectionLine', () => {
expect(path?.getAttribute('stroke-dasharray')).toBe('8 6');
expect(pulsingCircle).not.toBeNull();
});
+
+ it('publishes one dash period per cycle so the dash loop has no snap-back', () => {
+ function renderAt(pointer: { x: number; y: number }): SVGPathElement {
+ const { container } = render(
+
+ );
+ return container.querySelector('path') as SVGPathElement;
+ }
+
+ // Both patterns are 8 + 6 in some order, so both loop over 14 units.
+ const near = renderAt({ x: 110, y: 110 });
+ expect(near.getAttribute('stroke-dasharray')).toBe('8 6');
+ expect(near.style.getPropertyValue('--flow-connection-dash-period')).toBe('14');
+
+ const far = renderAt({ x: 900, y: 900 });
+ expect(far.getAttribute('stroke-dasharray')).toBe('6 8');
+ expect(far.style.getPropertyValue('--flow-connection-dash-period')).toBe('14');
+
+ // Publishing the period is useless unless the keyframes read it, so pin that too.
+ const keyframes = far.closest('g')?.querySelector('style')?.textContent ?? '';
+ expect(keyframes).toContain('var(--flow-connection-dash-period');
+ // The travelled distance is the `from` value; a hard-coded one is the bug.
+ expect(keyframes).not.toMatch(/from\s*\{\s*stroke-dashoffset:\s*\d/);
+ });
});
diff --git a/src/components/CustomConnectionLine.tsx b/src/components/CustomConnectionLine.tsx
index 66a69ee1..86b7a313 100644
--- a/src/components/CustomConnectionLine.tsx
+++ b/src/components/CustomConnectionLine.tsx
@@ -2,6 +2,7 @@ import React from 'react';
import { ConnectionLineComponentProps, getBezierPath, useNodes } from '@/lib/reactflowCompat';
import { NODE_WIDTH, NODE_HEIGHT } from '../constants';
import type { FlowNode } from '@/lib/types';
+import { getDashPatternPeriod } from './custom-edge/dashPattern';
const SNAP_PADDING = 56;
@@ -41,6 +42,11 @@ const CustomConnectionLine = ({
targetPosition: toPosition,
});
const connectionStroke = 'var(--brand-primary, #6366f1)';
+ const dashArray = isNearNode ? '8 6' : '6 8';
+ // Travel exactly one dash period per cycle, or the pattern snaps back on every loop.
+ // Left unset if it cannot be derived, so the keyframes' own fallback applies — a 0
+ // here would satisfy `var()` and freeze the animation instead.
+ const dashPeriod = getDashPatternPeriod(dashArray);
return (
@@ -48,12 +54,13 @@ const CustomConnectionLine = ({
fill="none"
stroke={connectionStroke}
strokeWidth={2.5}
- strokeDasharray={isNearNode ? '8 6' : '6 8'}
+ strokeDasharray={dashArray}
strokeLinecap="round"
style={{
filter: 'drop-shadow(0 1px 3px rgba(99,102,241,0.25))',
animation: 'flow-connection-dash 0.8s linear infinite',
- }}
+ ...(dashPeriod === null ? {} : { '--flow-connection-dash-period': dashPeriod }),
+ } as React.CSSProperties}
d={edgePath}
/>
@@ -87,7 +94,7 @@ const CustomConnectionLine = ({
{`
@keyframes flow-connection-dash {
from {
- stroke-dashoffset: 20;
+ stroke-dashoffset: var(--flow-connection-dash-period, 14);
}
to {
stroke-dashoffset: 0;
diff --git a/src/components/custom-edge/CustomEdgeWrapper.tsx b/src/components/custom-edge/CustomEdgeWrapper.tsx
index 0dcd3f0b..68536d50 100644
--- a/src/components/custom-edge/CustomEdgeWrapper.tsx
+++ b/src/components/custom-edge/CustomEdgeWrapper.tsx
@@ -9,7 +9,7 @@ import {
toMarkerUrl,
} from './classRelationSemantics';
import { resolveStandardEdgeMarkers } from './standardEdgeMarkers';
-import { resolveAnimatedEdgePresentation } from './animatedEdgePresentation';
+import { resolveAnimatedEdgePresentation, withDashPeriodVar } from './animatedEdgePresentation';
import {
buildEdgeLabelUpdates,
getEditableEdgeLabel,
@@ -115,7 +115,10 @@ export const CustomEdgeWrapper = memo(function CustomEdgeWrapper({
);
const resolvedStyle = useMemo(
- () => ({
+ // Animated edges loop `stroke-dashoffset`, which only joins up seamlessly when it
+ // travels one dash period per cycle, so publish the period of whatever pattern this
+ // edge ended up with. The CSS default covers edges that set none.
+ () => withDashPeriodVar({
stroke: designSystem.colors.edge,
strokeWidth: designSystem.components.edge.strokeWidth,
...style,
@@ -376,6 +379,10 @@ export const CustomEdgeWrapper = memo(function CustomEdgeWrapper({
fill="none"
stroke="rgba(15,23,42,0.001)"
strokeWidth={20}
+ // React Flow dashes and animates every path in an `.animated` edge, which
+ // would leave this hit target responding only on the moving dashes. A
+ // presentation attribute loses to its class rule, so override inline.
+ style={{ strokeDasharray: 'none', animation: 'none' }}
pointerEvents="stroke"
onPointerEnter={() => setIsHovered(true)}
onPointerLeave={() => setIsHovered(false)}
diff --git a/src/components/custom-edge/animatedEdgePresentation.test.ts b/src/components/custom-edge/animatedEdgePresentation.test.ts
index 0111757b..10355e29 100644
--- a/src/components/custom-edge/animatedEdgePresentation.test.ts
+++ b/src/components/custom-edge/animatedEdgePresentation.test.ts
@@ -1,5 +1,11 @@
+import type { CSSProperties } from 'react';
import { describe, expect, it } from 'vitest';
-import { resolveAnimatedEdgePresentation } from './animatedEdgePresentation';
+import {
+ DASH_PERIOD_CSS_VAR,
+ resolveAnimatedEdgePresentation,
+ withDashPeriodVar,
+} from './animatedEdgePresentation';
+import { getDashPatternPeriod } from './dashPattern';
describe('animated edge presentation', () => {
it('preserves hover and selection overlays when animated export is disabled', () => {
@@ -50,4 +56,106 @@ describe('animated edge presentation', () => {
expect(result.shouldRenderOverlay).toBe(false);
});
+
+ describe('dash loop period', () => {
+ function periodVarFor(dashArray: string | undefined): unknown {
+ const result = resolveAnimatedEdgePresentation({
+ animatedExportEnabled: true,
+ selected: false,
+ hovered: false,
+ edgeAnimated: true,
+ animationConfig: { enabled: true, state: 'active', dashArray },
+ baseStyle: { stroke: '#000', strokeWidth: 2 },
+ });
+
+ return (result.overlayStyle as Record)[DASH_PERIOD_CSS_VAR];
+ }
+
+ it('publishes one dash period per cycle for every dash preset', () => {
+ // The presets offered in EdgeStyleSection — each needs its own travel distance,
+ // otherwise the loop snaps back by the remainder every cycle.
+ expect(periodVarFor('8 4')).toBe(12);
+ expect(periodVarFor('2 4')).toBe(6);
+ expect(periodVarFor('8 4 2 4')).toBe(18);
+ });
+
+ it('publishes the period of the default pattern when none is configured', () => {
+ expect(periodVarFor(undefined)).toBe(16);
+ });
+
+ it('derives the period from the edge style when the animation sets no pattern', () => {
+ const result = resolveAnimatedEdgePresentation({
+ animatedExportEnabled: true,
+ selected: false,
+ hovered: false,
+ edgeAnimated: true,
+ animationConfig: { enabled: true, state: 'active' },
+ baseStyle: { stroke: '#000', strokeWidth: 2, strokeDasharray: '6 4' },
+ });
+
+ expect(result.overlayStyle.strokeDasharray).toBe('6 4');
+ expect((result.overlayStyle as Record)[DASH_PERIOD_CSS_VAR]).toBe(10);
+ });
+
+ it('always matches the period helper for whatever pattern it emits', () => {
+ const result = resolveAnimatedEdgePresentation({
+ animatedExportEnabled: false,
+ selected: true,
+ hovered: false,
+ edgeAnimated: false,
+ baseStyle: { stroke: '#000', strokeWidth: 2, strokeDasharray: '8 4 2 4' },
+ });
+
+ expect((result.overlayStyle as Record)[DASH_PERIOD_CSS_VAR])
+ .toBe(getDashPatternPeriod(result.overlayStyle.strokeDasharray));
+ });
+
+ it('leaves the var unset when the pattern has no resolvable period', () => {
+ expect(periodVarFor('10%')).toBeUndefined();
+ });
+ });
+
+ // This is the helper the visible edge path uses — the one animated edges actually
+ // render today. Without these, removing the call from CustomEdgeWrapper would
+ // reintroduce the snap-back on every dash preset with a green suite.
+ describe('withDashPeriodVar', () => {
+ function periodOf(style: CSSProperties): unknown {
+ return (withDashPeriodVar(style) as Record)[DASH_PERIOD_CSS_VAR];
+ }
+
+ it('publishes the period of the style it is given', () => {
+ expect(periodOf({ strokeDasharray: '8 4' })).toBe(12);
+ expect(periodOf({ strokeDasharray: '2 4' })).toBe(6);
+ expect(periodOf({ strokeDasharray: '8 4 2 4' })).toBe(18);
+ expect(periodOf({ strokeDasharray: '6 4' })).toBe(10);
+ });
+
+ it('leaves the var unset for an edge with no pattern, so the CSS default applies', () => {
+ // The "solid" preset writes an empty string, which React drops entirely; React
+ // Flow's own `stroke-dasharray: 5` then paints and the CSS default of 10 matches.
+ expect(periodOf({})).toBeUndefined();
+ expect(periodOf({ strokeDasharray: '' })).toBeUndefined();
+ });
+
+ it('never publishes 0, which would satisfy var() and freeze the animation', () => {
+ expect(periodOf({ strokeDasharray: '0 0' })).toBeUndefined();
+ expect(periodOf({ strokeDasharray: '10%' })).toBeUndefined();
+ });
+
+ it('keeps the rest of the style untouched', () => {
+ const result = withDashPeriodVar({ stroke: '#abc', strokeWidth: 3, strokeDasharray: '8 4' });
+
+ expect(result.stroke).toBe('#abc');
+ expect(result.strokeWidth).toBe(3);
+ expect(result.strokeDasharray).toBe('8 4');
+ });
+
+ it('always agrees with the period helper for the pattern it emits', () => {
+ for (const strokeDasharray of ['8 4', '2 4', '8 4 2 4', '6 4', '5', '8 8']) {
+ const result = withDashPeriodVar({ strokeDasharray });
+ expect((result as Record)[DASH_PERIOD_CSS_VAR], strokeDasharray)
+ .toBe(getDashPatternPeriod(strokeDasharray));
+ }
+ });
+ });
});
diff --git a/src/components/custom-edge/animatedEdgePresentation.ts b/src/components/custom-edge/animatedEdgePresentation.ts
index 4f61f117..c12d8d4a 100644
--- a/src/components/custom-edge/animatedEdgePresentation.ts
+++ b/src/components/custom-edge/animatedEdgePresentation.ts
@@ -1,5 +1,24 @@
import type { CSSProperties } from 'react';
import type { EdgeAnimationConfig } from '@/lib/types';
+import { getDashPatternPeriod } from './dashPattern';
+
+/**
+ * Distance the `flow-edge-dash` keyframes travel per cycle. Must equal the dash
+ * pattern's period or the loop snaps back by the remainder every cycle, so it is
+ * published per edge instead of being baked into the keyframes.
+ */
+export const DASH_PERIOD_CSS_VAR = '--flow-edge-dash-period';
+
+/**
+ * Publish the dash period of `style`'s own pattern so the loop travels exactly that far
+ * per cycle. Left unset when the pattern has no resolvable period, so the CSS default
+ * for the element applies — never set to 0, which would satisfy `var()`'s fallback and
+ * freeze the animation.
+ */
+export function withDashPeriodVar(style: CSSProperties): CSSProperties {
+ const period = getDashPatternPeriod(style.strokeDasharray);
+ return period === null ? style : ({ ...style, [DASH_PERIOD_CSS_VAR]: period } as CSSProperties);
+}
interface ResolveAnimatedEdgePresentationParams {
animatedExportEnabled: boolean;
@@ -23,14 +42,15 @@ export function resolveAnimatedEdgePresentation({
animationConfig,
baseStyle,
}: ResolveAnimatedEdgePresentationParams): AnimatedEdgePresentation {
- const overlayStyle: CSSProperties = {
+ const strokeDasharray = animationConfig?.dashArray
+ ?? (typeof baseStyle.strokeDasharray === 'string' && baseStyle.strokeDasharray.length > 0
+ ? baseStyle.strokeDasharray
+ : '8 8');
+ const overlayStyle: CSSProperties = withDashPeriodVar({
stroke: baseStyle.stroke,
strokeWidth: Math.max(Number(baseStyle.strokeWidth ?? 2), 2),
- strokeDasharray: animationConfig?.dashArray
- ?? (typeof baseStyle.strokeDasharray === 'string' && baseStyle.strokeDasharray.length > 0
- ? baseStyle.strokeDasharray
- : '8 8'),
- };
+ strokeDasharray,
+ });
if (!animatedExportEnabled) {
return {
diff --git a/src/components/custom-edge/dashPattern.test.ts b/src/components/custom-edge/dashPattern.test.ts
new file mode 100644
index 00000000..d599a5d6
--- /dev/null
+++ b/src/components/custom-edge/dashPattern.test.ts
@@ -0,0 +1,58 @@
+import { describe, expect, it } from 'vitest';
+import { getDashPatternPeriod } from './dashPattern';
+
+describe('getDashPatternPeriod', () => {
+ it('sums an even-length pattern', () => {
+ expect(getDashPatternPeriod('8 8')).toBe(16);
+ expect(getDashPatternPeriod('8 4')).toBe(12);
+ expect(getDashPatternPeriod('2 4')).toBe(6);
+ expect(getDashPatternPeriod('8 4 2 4')).toBe(18);
+ });
+
+ it('doubles an odd-length pattern, as SVG repeats the list to make it even', () => {
+ expect(getDashPatternPeriod('6')).toBe(12);
+ expect(getDashPatternPeriod('8 4 2')).toBe(28);
+ });
+
+ it('accepts a bare number as a one-entry list', () => {
+ expect(getDashPatternPeriod(6)).toBe(12);
+ });
+
+ it('accepts comma separators, extra whitespace and px units', () => {
+ expect(getDashPatternPeriod('8, 4')).toBe(12);
+ expect(getDashPatternPeriod(' 8 4 ')).toBe(12);
+ expect(getDashPatternPeriod('8px 4px')).toBe(12);
+ });
+
+ it('supports fractional values', () => {
+ expect(getDashPatternPeriod('1.5 2.5')).toBe(4);
+ });
+
+ it('returns null when there is no resolvable dash pattern', () => {
+ expect(getDashPatternPeriod(undefined)).toBeNull();
+ expect(getDashPatternPeriod('')).toBeNull();
+ expect(getDashPatternPeriod(' ')).toBeNull();
+ expect(getDashPatternPeriod('none')).toBeNull();
+ expect(getDashPatternPeriod('0 0')).toBeNull();
+ expect(getDashPatternPeriod(0)).toBeNull();
+ });
+
+ it('returns null for units it cannot resolve without the path length', () => {
+ expect(getDashPatternPeriod('10%')).toBeNull();
+ expect(getDashPatternPeriod('8 10%')).toBeNull();
+ expect(getDashPatternPeriod('2em')).toBeNull();
+ });
+
+ it('returns null for invalid patterns rather than guessing', () => {
+ expect(getDashPatternPeriod('8 -4')).toBeNull();
+ expect(getDashPatternPeriod('8 abc')).toBeNull();
+ expect(getDashPatternPeriod('NaN')).toBeNull();
+ });
+
+ it('rejects numbers CSS itself rejects, so a dropped declaration cannot get a period', () => {
+ // `8.` is not a valid CSS number, so the browser drops the whole declaration and
+ // paints something else. Publishing a period for it would reintroduce the snap.
+ expect(getDashPatternPeriod('8.')).toBeNull();
+ expect(getDashPatternPeriod('8. 4')).toBeNull();
+ });
+});
diff --git a/src/components/custom-edge/dashPattern.ts b/src/components/custom-edge/dashPattern.ts
new file mode 100644
index 00000000..b45c8872
--- /dev/null
+++ b/src/components/custom-edge/dashPattern.ts
@@ -0,0 +1,42 @@
+/**
+ * A `stroke-dashoffset` animation only loops seamlessly when the distance it travels
+ * per cycle equals the dash pattern's period. Travel a different distance and every
+ * cycle boundary snaps the pattern back by the remainder — obvious on an irregular
+ * pattern such as dash-dot, where the eye tracks individual dots.
+ */
+
+// CSS requires a digit after the decimal point, so `8.` is an invalid declaration the
+// browser drops. Accepting it here would publish a period for a pattern that never
+// paints — the exact silent mismatch this module exists to prevent.
+const NUMBER_WITH_OPTIONAL_PX = /^(-?(?:\d+(?:\.\d+)?|\.\d+))(?:px)?$/;
+
+/**
+ * Distance a `stroke-dashoffset` animation must travel for one seamless loop of
+ * `dashArray`, or `null` when that cannot be determined — no pattern, a zero-length
+ * pattern, or units that need the path length (`%`) or a font context (`em`).
+ *
+ * Per SVG, a list with an odd number of entries is repeated to yield an even count,
+ * so its period is twice the sum.
+ */
+export function getDashPatternPeriod(dashArray: string | number | undefined | null): number | null {
+ if (dashArray === undefined || dashArray === null) return null;
+
+ const entries = String(dashArray)
+ .trim()
+ .split(/[\s,]+/)
+ .filter((entry) => entry.length > 0);
+ if (entries.length === 0) return null;
+
+ let sum = 0;
+ for (const entry of entries) {
+ const match = NUMBER_WITH_OPTIONAL_PX.exec(entry);
+ if (!match) return null;
+
+ const value = Number(match[1]);
+ if (!Number.isFinite(value) || value < 0) return null;
+ sum += value;
+ }
+
+ if (sum <= 0) return null;
+ return entries.length % 2 === 0 ? sum : sum * 2;
+}
diff --git a/src/index.css b/src/index.css
index 5342b81f..47b1b34b 100644
--- a/src/index.css
+++ b/src/index.css
@@ -687,12 +687,43 @@ body {
}
.flow-edge-animated-overlay {
+ /* Period of this overlay's own default `8 8` pattern; overridden inline per edge. */
+ --flow-edge-dash-period: 16;
animation: flow-edge-dash 0.6s linear infinite;
}
+/*
+ * The overlay only ever renders inside an `.animated` edge, where React Flow's
+ * `.react-flow__edge.animated path` (0,2,1) would otherwise outrank the rule above and
+ * hijack it back to `dashdraw`'s fixed travel of 10. Three classes wins.
+ */
+.react-flow__edge.animated .flow-edge-animated-overlay {
+ animation: flow-edge-dash 0.6s linear infinite;
+}
+
+/*
+ * React Flow animates every path in an `.animated` edge with its own `dashdraw`, which
+ * travels a fixed 10 — right for its own `stroke-dasharray: 5`, wrong for any pattern
+ * the app sets inline (the dash-dot preset has period 18, so each cycle snapped back
+ * by 8). Replace it with the period-aware animation. Three classes outweighs React
+ * Flow's `.react-flow__edge.animated path`, so this wins without `!important`.
+ */
+.react-flow__edge.animated .react-flow__edge-path {
+ /* Period of React Flow's `stroke-dasharray: 5`, used when the edge sets no pattern. */
+ --flow-edge-dash-period: 10;
+ animation: flow-edge-dash 0.5s linear infinite;
+}
+
+/*
+ * The travelled distance must equal the dash pattern's period, otherwise every cycle
+ * boundary snaps the pattern back by the remainder — very visible on an irregular
+ * pattern such as dash-dot, where the eye tracks individual dots.
+ * `--flow-edge-dash-period` is published per element; each consumer above declares the
+ * default for its own pattern.
+ */
@keyframes flow-edge-dash {
from {
- stroke-dashoffset: 16;
+ stroke-dashoffset: var(--flow-edge-dash-period, 10);
}
to {