refactor(edges): use CSS animations for snake/flow edges instead of SVG SMIL
Snake and flow edge animations used SVG SMIL <animate repeatCount="indefinite">. 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 <animate> nodes and a CSS animation instead. ha-relevant: yes
This commit is contained in:
@@ -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 <animate>.
|
||||
*
|
||||
* SMIL <animate> 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 <animate> nodes.
|
||||
*/
|
||||
function renderEdge(data: Partial<EdgeData> = {}) {
|
||||
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<Edge<EdgeData>>
|
||||
|
||||
return render(
|
||||
<ReactFlowProvider>
|
||||
<svg>
|
||||
<HomelableEdge {...props} />
|
||||
</svg>
|
||||
</ReactFlowProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('HomelableEdge animation', () => {
|
||||
it('renders snake animation as CSS, not SMIL <animate>', () => {
|
||||
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 <animate>', () => {
|
||||
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 <animate>', () => {
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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 ? (
|
||||
<animate attributeName="stroke-dashoffset" values="-10000;0;-10000" keyTimes="0;0.5;1" dur="20s" repeatCount="indefinite" />
|
||||
) : (
|
||||
<animate attributeName="stroke-dashoffset" from="-10000" to="0" dur="10s" repeatCount="indefinite" />
|
||||
)}
|
||||
</path>
|
||||
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' && (
|
||||
<path
|
||||
@@ -396,10 +396,11 @@ export function HomelableEdge({ id, source, target, sourceHandleId, targetHandle
|
||||
strokeDasharray="6 12"
|
||||
strokeLinecap="round"
|
||||
strokeOpacity={0.85}
|
||||
style={{ pointerEvents: 'none' }}
|
||||
>
|
||||
<animate attributeName="stroke-dashoffset" from="0" to="18" dur="1.2s" repeatCount="indefinite" />
|
||||
</path>
|
||||
style={{
|
||||
pointerEvents: 'none',
|
||||
animation: 'homelable-flow 1.2s linear infinite',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<EdgeLabelRenderer>
|
||||
|
||||
@@ -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 <animate> 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. */
|
||||
|
||||
Reference in New Issue
Block a user