Handle offsets are inflated by ancestor CSS transform/zoom (handle bounds divide by the viewport zoom only)
What platform were you using when you found the bug?
React Flow 12.11.6 (@xyflow/system 0.0.82), Chrome 152 on macOS. Also reproduced on 12.10.0. The code involved is in @xyflow/system, so Svelte Flow should be affected the same way.
Live code example
Single HTML file, no build step (loads the published package from esm.sh):
<!doctype html>
<meta charset="utf-8">
<title>React Flow: handle offsets under an ancestor CSS transform</title>
<link rel="stylesheet" href="https://esm.sh/@xyflow/[email protected]/dist/style.css">
<style>
body { font: 14px system-ui, sans-serif; margin: 24px; }
.flow { width: 600px; height: 240px; border: 1px solid #bbb; }
#scaled { transform: scale(1.5); transform-origin: 0 0; }
</style>
<h3>1. No ancestor transform: edge meets the handles</h3>
<div class="flow" id="plain"></div>
<h3 style="margin-top: 40px">2. Same flow inside <code>transform: scale(1.5)</code>: edge misses the handles</h3>
<div class="flow" id="scaled"></div>
<script type="importmap">
{ "imports": {
"react": "https://esm.sh/[email protected]",
"react-dom/client": "https://esm.sh/[email protected]/client",
"@xyflow/react": "https://esm.sh/@xyflow/[email protected][email protected],[email protected]"
} }
</script>
<script type="module">
import React from 'react';
import { createRoot } from 'react-dom/client';
import { ReactFlow } from '@xyflow/react';
const nodes = [
{ id: 'a', position: { x: 0, y: 0 }, data: { label: 'A' }, sourcePosition: 'right', targetPosition: 'left' },
{ id: 'b', position: { x: 300, y: 120 }, data: { label: 'B' }, sourcePosition: 'right', targetPosition: 'left' },
];
const edges = [{ id: 'a-b', source: 'a', target: 'b' }];
for (const id of ['plain', 'scaled']) {
createRoot(document.getElementById(id)).render(
React.createElement(ReactFlow, { nodes, edges, fitView: true, nodesDraggable: false })
);
}
</script>Describe the Bug
When a flow sits inside an element that carries a CSS transform: scale() (or CSS zoom), every handle offset stored in node.internals.handleBounds is multiplied by that scale, while node positions and measured sizes stay correct. Edges therefore start and end away from their handles, and the error grows with the handle's distance from the node's top-left corner.
Cause: updateNodeInternals in packages/system/src/utils/store.ts takes zoom from the .xyflow__viewport transform and passes it to getHandleBounds (packages/system/src/utils/dom.ts), which computes
x: (handleBounds.left - nodeBounds.left) / zoom,
y: (handleBounds.top - nodeBounds.top) / zoom,Both rects come from getBoundingClientRect(), which includes every ancestor transform, so the difference is ancestorScale * zoom * offset and dividing by zoom alone leaves ancestorScale * offset. Node dimensions use offsetWidth/offsetHeight, which ancestor transforms do not touch, so nodes render in the right place while the handle offsets are inflated. The handle's own width/height also stay unscaled.
Where this shows up in practice: reveal.js and Quarto slide decks scale the whole deck with transform: scale(s) to fit the window, so a flow on a slide breaks in fullscreen. It is intermittent there, because handle bounds are only re-measured when a node's ResizeObserver fires with a non-zero size: at mount, and when the slide returns from display: none. Bounds measured at scale 1 survive a later scale change; bounds measured while scaled are wrong until the next re-measure.
Steps to reproduce the bug or issue
- Save the HTML above and open it in Chrome.
- Flow 1 (no ancestor transform): the edge leaves A's right handle and arrives at B's left handle.
- Flow 2 (same flow inside
transform: scale(1.5)): the edge starts about 77 px to the right of A's handle and ends below B's handle.
Measured from the DOM (flow units, relative to each node's top-left; the source handle centre sits at (149, 18) in both flows):
| edge start (source side) | edge end (target side) | |
|---|---|---|
| no transform | (153, 18), i.e. handle centre + half the 8 px handle | (-3, 18) |
scale(1.5) |
(225.5, 25) = 1.5 × (149 - 4) + 8, 1.5 × (18 - 4) + 4 | (-4.5, 25) |
The stored offsets are exactly the true offsets times the ancestor scale, plus the unscaled handle size.
Expected behavior
The edge meets the same handles in both flows. Ancestor transforms should not change where edges attach, since node positions and sizes already ignore them.
Screenshots or Videos
No image attached; the HTML above renders the two flows side by side, and the screenshot is easy to take from it.
Additional context
A small fix in updateNodeInternals makes the handle measurement invariant to ancestor scaling. The container's bounding-rect width over its offsetWidth is exactly the ancestor scale (the .react-flow element carries no transform of its own), and it folds into the zoom used for the conversion:
const style = window.getComputedStyle(viewportNode);
const { m22: zoom } = new window.DOMMatrixReadOnly(style.transform);
+ // Ancestor transforms and CSS zoom are in getBoundingClientRect() but not
+ // in offsetWidth. getHandleBounds divides screen offsets by this factor,
+ // so it has to include that scale.
+ const domRect = domNode.getBoundingClientRect();
+ const ancestorScale =
+ domNode.offsetWidth > 0 && domRect.width > 0 ? domRect.width / domNode.offsetWidth : 1;
+ const handleZoom = zoom * ancestorScale;
...
- source: getHandleBounds('source', update.nodeElement, nodeBounds, zoom, node.id),
- target: getHandleBounds('target', update.nodeElement, nodeBounds, zoom, node.id),
+ source: getHandleBounds('source', update.nodeElement, nodeBounds, handleZoom, node.id),
+ target: getHandleBounds('target', update.nodeElement, nodeBounds, handleZoom, node.id),With no ancestor transform the ratio is 1 up to subpixel rounding, so unscaled pages measure exactly as before. I have been running this as a patch-package patch on 0.0.74 in an R htmlwidget that embeds React Flow (patch file). Measured with the patch: edges land at half a handle width from their handles at ancestor scales 1.58, 1.4, 0.65 and under CSS zoom: 0.8, including after a hide/show cycle, and unchanged at scale 1. The zero-width guard covers an ancestor mid-animation at scale(0), where the ratio would otherwise be 0/0.
Related but not covered by this diff: pointer math (node drag and viewport pan) has the same blind spot, so dragging inside a scaled container moves the node faster than the cursor by the same factor. If this approach fits, the diff is small enough to land with a test in packages/system.
Source: xyflow/xyflow