fix(frontend): clear the flow graph selection through xyflow's store (#11056)

Selecting a step in the flow editor sometimes opened the Settings panel
instead of the step that was clicked.

SelectionManager.selectId() clears xyflow's own selection before setting
ours, and that clear was wired to clearFlowSelection(), which resets
offsetNodeCache and reassigns nodes. That hands xyflow node objects it
does not recognise, which is what makes it drop the flag — but it also
makes it re-create every node's DOM: 126 elements on a 25-node flow, on
every selection.

Graph nodes select on pointerdown. A click is dispatched on the closest
common ancestor of its pointerdown and pointerup targets, so when the
release lands in that teardown gap the browser hit-tests to the pane, the
click addresses the pane, and onpaneclick clears the selection back to the
settings sentinel.

Clear through store.unselectNodesAndEdges() instead, so no node object
changes identity and there is no gap to fall into. clearFlowSelection
keeps its two group-creation callers, which rebuild the graph anyway.


Claude-Session: https://claude.ai/code/session_01R7caEKonPr2DmvsWRR6bCK

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem
2026-09-10 16:37:10 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent 9d75929247
commit b4be8bc535
3 changed files with 12 additions and 10 deletions
@@ -734,11 +734,9 @@
return false
}
// Clear SvelteFlow's internal selection by creating new nodes array
function clearFlowSelection() {
// xyflow owns `selected` on the objects it was handed, and drops it only when it sees a
// node it does not recognise. Serving the cached mapping back would hand it the very
// object it marked selected, so the clear has to go through fresh objects.
// Resetting the cache and reassigning `nodes` hands xyflow objects it has not seen, the
// only lever on its selection available from our own array.
offsetNodeCache = new WeakMap<Node, Node>()
nodes = nodes.map((node) => {
if (node.selected) {
@@ -1411,7 +1409,7 @@
/>
<!-- SelectionTool for handling selection changes and filtering -->
<SelectionTool {selectionManager} clearGraphSelection={clearFlowSelection} />
<SelectionTool {selectionManager} />
{#if leftHeader}
<div class="absolute top-2 left-2 z-10">
@@ -4,16 +4,18 @@
import type { SelectionManager } from './selectionUtils.svelte'
interface Props {
selectionManager: SelectionManager
clearGraphSelection: () => void
}
let { selectionManager, clearGraphSelection }: Props = $props()
let { selectionManager }: Props = $props()
untrack(() => selectionManager).setClearGraphSelection(untrack(() => clearGraphSelection))
// Get store to access selectionRect
const store = useStore()
// Clear through xyflow's store, never by handing it fresh node objects: replacing them
// re-creates every node's DOM, and a click whose node is rebuilt between pointerdown and
// release retargets to the pane, which clears the selection that same gesture just made.
// While xyflow holds a selection, useOnSelectionChange below re-broadcasts it over ours.
untrack(() => selectionManager).setClearGraphSelection(() => store.unselectNodesAndEdges())
// Handle selection changes from SvelteFlow
useOnSelectionChange(({ nodes: selectedNodes, edges: _selectedEdges }) => {
// Notes are already non-selectable, so no filtering needed
@@ -12,6 +12,8 @@ export type GraphContext = {
showAssets: Writable<boolean | undefined>
noteManager?: NoteManager
moveManager?: MoveManager
/** Clears xyflow's selection by replacing every node object, so only for callers that rebuild
* the graph anyway. A selection change must use `selectionManager` instead. */
clearFlowSelection?: () => void
yOffset?: number
diffManager: FlowDiffManager