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}
|
strokeWidth={((style.strokeWidth as number ?? 2) + 1.5) * 2}
|
||||||
strokeDasharray="20 10000"
|
strokeDasharray="20 10000"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
style={{ pointerEvents: 'none' }}
|
style={{
|
||||||
>
|
pointerEvents: 'none',
|
||||||
{isBidirectional ? (
|
// CSS (not SMIL) so it pauses when the tab is hidden — see index.css.
|
||||||
<animate attributeName="stroke-dashoffset" values="-10000;0;-10000" keyTimes="0;0.5;1" dur="20s" repeatCount="indefinite" />
|
// Bidirectional yo-yos via `alternate` (10s each way = 20s round trip,
|
||||||
) : (
|
// matching the old SMIL keyTimes); unidirectional loops in one direction.
|
||||||
<animate attributeName="stroke-dashoffset" from="-10000" to="0" dur="10s" repeatCount="indefinite" />
|
animation: `homelable-snake 10s linear infinite${isBidirectional ? ' alternate' : ''}`,
|
||||||
)}
|
}}
|
||||||
</path>
|
/>
|
||||||
)}
|
)}
|
||||||
{animMode === 'flow' && (
|
{animMode === 'flow' && (
|
||||||
<path
|
<path
|
||||||
@@ -396,10 +396,11 @@ export function HomelableEdge({ id, source, target, sourceHandleId, targetHandle
|
|||||||
strokeDasharray="6 12"
|
strokeDasharray="6 12"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeOpacity={0.85}
|
strokeOpacity={0.85}
|
||||||
style={{ pointerEvents: 'none' }}
|
style={{
|
||||||
>
|
pointerEvents: 'none',
|
||||||
<animate attributeName="stroke-dashoffset" from="0" to="18" dur="1.2s" repeatCount="indefinite" />
|
animation: 'homelable-flow 1.2s linear infinite',
|
||||||
</path>
|
}}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<EdgeLabelRenderer>
|
<EdgeLabelRenderer>
|
||||||
|
|||||||
@@ -11,6 +11,18 @@
|
|||||||
to { stroke-dashoffset: 0; }
|
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
|
/* Disable React Flow's built-in edgeupdater entirely — HomelableEdge renders
|
||||||
its own interactive endpoint dots in EdgeLabelRenderer (above the node
|
its own interactive endpoint dots in EdgeLabelRenderer (above the node
|
||||||
layer) so the node Handle DOM cannot steal the reconnection drag. */
|
layer) so the node Handle DOM cannot steal the reconnection drag. */
|
||||||
|
|||||||
Reference in New Issue
Block a user