From 2375efa6b77e53fd241c35d91dd8b8ca71f74630 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Fri, 5 Jun 2026 01:54:54 +0200 Subject: [PATCH] refactor(edges): use CSS animations for snake/flow edges instead of SVG SMIL Snake and flow edge animations used SVG SMIL . SMIL keeps running while a tab is hidden (CSS animations pause) and grows memory in Chrome over long-lived background tabs. Convert both to CSS @keyframes (homelable-snake / homelable-flow), matching the existing basic-dash pattern. Identical visuals and timings; bidirectional snake yo-yos via `alternate`. Adds a regression test asserting animated edges emit zero nodes and a CSS animation instead. ha-relevant: yes --- .../HomelableEdge.animation.test.tsx | 77 +++++++++++++++++++ .../src/components/canvas/edges/index.tsx | 25 +++--- frontend/src/index.css | 12 +++ 3 files changed, 102 insertions(+), 12 deletions(-) create mode 100644 frontend/src/components/canvas/edges/__tests__/HomelableEdge.animation.test.tsx diff --git a/frontend/src/components/canvas/edges/__tests__/HomelableEdge.animation.test.tsx b/frontend/src/components/canvas/edges/__tests__/HomelableEdge.animation.test.tsx new file mode 100644 index 0000000..346c122 --- /dev/null +++ b/frontend/src/components/canvas/edges/__tests__/HomelableEdge.animation.test.tsx @@ -0,0 +1,77 @@ +import { describe, it, expect } from 'vitest' +import { render } from '@testing-library/react' +import { ReactFlowProvider } from '@xyflow/react' +import type { EdgeProps, Edge } from '@xyflow/react' +import { HomelableEdge } from '../index' +import type { EdgeData } from '@/types' + +/** + * Regression: edge flow animations must use CSS, never SVG SMIL . + * + * SMIL keeps running while the tab is hidden and leaks memory in + * Chrome over time (RAM climbed only when the canvas tab was backgrounded). + * CSS animations pause when the tab is hidden and don't leak — so the rendered + * output must contain a CSS `animation` on the path and zero nodes. + */ +function renderEdge(data: Partial = {}) { + const props = { + id: 'e1', + source: 'a', + target: 'b', + sourceX: 0, + sourceY: 0, + targetX: 100, + targetY: 100, + sourcePosition: 'bottom', + targetPosition: 'top', + data: { type: 'ethernet', ...data } as EdgeData, + selected: false, + } as unknown as EdgeProps> + + return render( + + + + + , + ) +} + +describe('HomelableEdge animation', () => { + it('renders snake animation as CSS, not SMIL ', () => { + const { container } = renderEdge({ animated: 'snake' }) + expect(container.querySelector('animate')).toBeNull() + const animated = Array.from(container.querySelectorAll('path')).find((p) => + (p.getAttribute('style') ?? '').includes('homelable-snake'), + ) + expect(animated).toBeTruthy() + }) + + it('renders flow animation as CSS, not SMIL ', () => { + const { container } = renderEdge({ animated: 'flow' }) + expect(container.querySelector('animate')).toBeNull() + const animated = Array.from(container.querySelectorAll('path')).find((p) => + (p.getAttribute('style') ?? '').includes('homelable-flow'), + ) + expect(animated).toBeTruthy() + }) + + it('legacy animated:true maps to snake CSS animation', () => { + const { container } = renderEdge({ animated: true }) + expect(container.querySelector('animate')).toBeNull() + const animated = Array.from(container.querySelectorAll('path')).find((p) => + (p.getAttribute('style') ?? '').includes('homelable-snake'), + ) + expect(animated).toBeTruthy() + }) + + it('non-animated edge has no flow animation and no ', () => { + const { container } = renderEdge({ animated: false }) + expect(container.querySelector('animate')).toBeNull() + const animated = Array.from(container.querySelectorAll('path')).find((p) => { + const s = p.getAttribute('style') ?? '' + return s.includes('homelable-snake') || s.includes('homelable-flow') + }) + expect(animated).toBeUndefined() + }) +}) diff --git a/frontend/src/components/canvas/edges/index.tsx b/frontend/src/components/canvas/edges/index.tsx index 54f64b9..b7e6afd 100644 --- a/frontend/src/components/canvas/edges/index.tsx +++ b/frontend/src/components/canvas/edges/index.tsx @@ -378,14 +378,14 @@ export function HomelableEdge({ id, source, target, sourceHandleId, targetHandle strokeWidth={((style.strokeWidth as number ?? 2) + 1.5) * 2} strokeDasharray="20 10000" strokeLinecap="round" - style={{ pointerEvents: 'none' }} - > - {isBidirectional ? ( - - ) : ( - - )} - + style={{ + pointerEvents: 'none', + // CSS (not SMIL) so it pauses when the tab is hidden — see index.css. + // Bidirectional yo-yos via `alternate` (10s each way = 20s round trip, + // matching the old SMIL keyTimes); unidirectional loops in one direction. + animation: `homelable-snake 10s linear infinite${isBidirectional ? ' alternate' : ''}`, + }} + /> )} {animMode === 'flow' && ( - - + style={{ + pointerEvents: 'none', + animation: 'homelable-flow 1.2s linear infinite', + }} + /> )} diff --git a/frontend/src/index.css b/frontend/src/index.css index 419df7e..0b429c8 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -11,6 +11,18 @@ to { stroke-dashoffset: 0; } } +/* Edge flow animations — CSS (not SVG SMIL) so the browser pauses them when the + tab is hidden. SMIL keeps running in background tabs and leaks + memory in Chrome over time; CSS animations do neither. */ +@keyframes homelable-snake { + from { stroke-dashoffset: -10000; } + to { stroke-dashoffset: 0; } +} +@keyframes homelable-flow { + from { stroke-dashoffset: 0; } + to { stroke-dashoffset: 18; } +} + /* Disable React Flow's built-in edgeupdater entirely — HomelableEdge renders its own interactive endpoint dots in EdgeLabelRenderer (above the node layer) so the node Handle DOM cannot steal the reconnection drag. */