Skip to content

Commit b9b9c9c

Browse files
authored
fix(canvas): stop phantom ports and a latched-open action bar (#6688)
* fix(canvas): stop phantom ports and a latched-open action bar Ports surface on hover from a swell painted on the card border, and that swell was raised with no regard for whether a handle exists behind it. A Response block mounts no source handle, so hovering its edge raised a knob no edge could ever leave from. A trigger mounts no target handle, yet still swelled under a connection dragged from another card, offering a drop it cannot accept. Gate each direction on the handle that backs it, and limit a trigger's own swell to its source edge the way the subflow start node already does. The action bar latched open for the same interaction. Leaving the card arms a retract and installs a pointermove listener to track the pointer across the gap up to the bar; re-entering the bar's band called openHover(), which cancelled the retract AND removed that listener. No further pointerleave can arrive once the pointer is off the node, so nothing was left to close the bar. Keep the listener installed and re-arm the retract when the pointer moves back out. Also clear the magnetized port when the pointer leaves the tracking band onto the action bar: only the in-band path recomputed it, so the last knob stayed pinned at hover amplitude with the pointer nowhere near it. * fix(canvas): scope the receive gate, cover both swell directions Gating the foreign-drag listener also ran the shared pointer-tracking reset, which belongs to the card's own hover. A trigger sets canReceiveConnection false while canStartConnection stays true, so the reset undid the layout effect's :hover bootstrap and left a card that mounted under the pointer with no source swell until the pointer left and came back. Skip the listener instead; the effect's own cleanup already covers a true-to-false flip. Drop the trigger-only cursorSwellSides restriction. A swell on a trigger's input edge resolves to a source handle, so an edge genuinely can be made there — it was a behavior change beyond the bug, not a phantom. Cover both directions of the swell gate, and use the shared sleep helper in the action-bar test. Hoist the constant connection sides out of the render body so they stop riding the borderPorts dep array.
1 parent 9436a93 commit b9b9c9c

5 files changed

Lines changed: 448 additions & 26 deletions

File tree

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*
4+
* The action bar floats above the card, so the pointer crosses a gap to reach
5+
* it and the card's own `pointerleave` fires on the way out. The hook tracks
6+
* the pointer across that gap — and the tracking listener is the only thing
7+
* still watching once the pointer is off the node, because no further
8+
* `pointerleave` can arrive.
9+
*/
10+
11+
import { act, useEffect } from 'react'
12+
import { sleep } from '@sim/utils/helpers'
13+
import { createRoot, type Root } from 'react-dom/client'
14+
import { afterEach, beforeAll, describe, expect, it } from 'vitest'
15+
import { useActionMenuSwell } from './use-action-menu-swell'
16+
17+
beforeAll(() => {
18+
globalThis.ResizeObserver = class {
19+
observe() {}
20+
unobserve() {}
21+
disconnect() {}
22+
} as unknown as typeof ResizeObserver
23+
})
24+
25+
/** Clears the hook's 100ms hover-leave delay and its 40ms swell close with margin. */
26+
const SETTLE_PASS_MS = 160
27+
const ACTION_MENU_RECT = { left: 100, right: 240, top: 40, bottom: 70 }
28+
/** Comfortably outside the bar's hover band, which extends 28px above `top`. */
29+
const AWAY_POINT = { clientX: 600, clientY: 400 }
30+
31+
let container: HTMLDivElement | null = null
32+
let root: Root | null = null
33+
34+
interface HarnessProps {
35+
onState: (state: { swellOpen: boolean }) => void
36+
}
37+
38+
/**
39+
* Mounts the hook under a `.react-flow__node` ancestor, since the hook binds
40+
* its hover listeners to that element rather than to its own root.
41+
*/
42+
function Harness({ onState }: HarnessProps) {
43+
const { rootRef, swellOpen } = useActionMenuSwell({ enabled: true, forceOpen: false })
44+
45+
useEffect(() => {
46+
onState({ swellOpen })
47+
}, [onState, swellOpen])
48+
49+
return (
50+
<div className='react-flow__node'>
51+
<div ref={rootRef} data-testid='action-menu' />
52+
</div>
53+
)
54+
}
55+
56+
function mount(onState: (state: { swellOpen: boolean }) => void) {
57+
container = document.createElement('div')
58+
document.body.appendChild(container)
59+
root = createRoot(container)
60+
act(() => {
61+
root?.render(<Harness onState={onState} />)
62+
})
63+
64+
const node = container.querySelector<HTMLElement>('.react-flow__node')
65+
const menu = container.querySelector<HTMLElement>('[data-testid="action-menu"]')
66+
if (!node || !menu) throw new Error('harness did not mount')
67+
68+
menu.getBoundingClientRect = () =>
69+
({
70+
...ACTION_MENU_RECT,
71+
width: ACTION_MENU_RECT.right - ACTION_MENU_RECT.left,
72+
height: ACTION_MENU_RECT.bottom - ACTION_MENU_RECT.top,
73+
x: ACTION_MENU_RECT.left,
74+
y: ACTION_MENU_RECT.top,
75+
toJSON: () => ({}),
76+
}) as DOMRect
77+
78+
return { node, menu }
79+
}
80+
81+
function pointerEvent(type: string, init: PointerEventInit) {
82+
return new MouseEvent(type, { bubbles: true, ...init }) as unknown as PointerEvent
83+
}
84+
85+
/**
86+
* Drains the two chained timers the retract runs on: the 100ms hover-leave
87+
* delay, then the 40ms swell close that its state change schedules. They need
88+
* separate `act` passes — effects only flush when `act` exits, so the second
89+
* timer is not even created until the first pass is over.
90+
*/
91+
async function settle() {
92+
for (let pass = 0; pass < 2; pass++) {
93+
await act(async () => {
94+
await sleep(SETTLE_PASS_MS)
95+
})
96+
}
97+
}
98+
99+
afterEach(() => {
100+
act(() => {
101+
root?.unmount()
102+
})
103+
root = null
104+
container?.remove()
105+
container = null
106+
})
107+
108+
describe('useActionMenuSwell', () => {
109+
it('retracts after the pointer crosses the bar and keeps going', async () => {
110+
let state = { swellOpen: false }
111+
const { node } = mount((next) => {
112+
state = next
113+
})
114+
115+
act(() => {
116+
node.dispatchEvent(pointerEvent('pointerenter', {}))
117+
})
118+
expect(state.swellOpen).toBe(true)
119+
120+
act(() => {
121+
node.dispatchEvent(pointerEvent('pointerleave', {}))
122+
})
123+
124+
/*
125+
* One move lands inside the bar's hover band on the way out. This is the
126+
* regression: it used to cancel the retract AND tear down the tracker, so
127+
* nothing was left to close the bar once the pointer carried on.
128+
*/
129+
act(() => {
130+
window.dispatchEvent(pointerEvent('pointermove', { clientX: 150, clientY: 50 }))
131+
})
132+
expect(state.swellOpen).toBe(true)
133+
134+
act(() => {
135+
window.dispatchEvent(pointerEvent('pointermove', AWAY_POINT))
136+
})
137+
138+
await settle()
139+
140+
expect(state.swellOpen).toBe(false)
141+
})
142+
143+
it('stays open while the pointer rests on the bar', async () => {
144+
let state = { swellOpen: false }
145+
const { node } = mount((next) => {
146+
state = next
147+
})
148+
149+
act(() => {
150+
node.dispatchEvent(pointerEvent('pointerenter', {}))
151+
node.dispatchEvent(pointerEvent('pointerleave', {}))
152+
})
153+
154+
act(() => {
155+
window.dispatchEvent(pointerEvent('pointermove', { clientX: 150, clientY: 50 }))
156+
})
157+
158+
await settle()
159+
160+
expect(state.swellOpen).toBe(true)
161+
})
162+
})

packages/workflow-renderer/src/workflow-block/use-action-menu-swell.ts

Lines changed: 42 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -59,11 +59,15 @@ export function useActionMenuSwell({
5959
useEffect(() => {
6060
if (!enabled || suspendInteraction) return
6161

62-
const clearHoverLeave = () => {
62+
const cancelHoverLeaveTimeout = () => {
6363
if (hoverLeaveTimeoutRef.current !== null) {
6464
window.clearTimeout(hoverLeaveTimeoutRef.current)
6565
hoverLeaveTimeoutRef.current = null
6666
}
67+
}
68+
69+
const clearHoverLeave = () => {
70+
cancelHoverLeaveTimeout()
6771
if (hoverMoveRef.current) {
6872
window.removeEventListener('pointermove', hoverMoveRef.current)
6973
hoverMoveRef.current = null
@@ -111,33 +115,56 @@ export function useActionMenuSwell({
111115
openHover()
112116
}
113117

118+
/**
119+
* Arms the retract timer, unless one is already counting down. Never
120+
* touches the tracking listener: once the pointer has left the node, that
121+
* listener is the only thing still watching it, so tearing it down here
122+
* would strand the menu open with nothing left to close it.
123+
*/
124+
const armHoverLeave = () => {
125+
if (hoverLeaveTimeoutRef.current !== null) return
126+
hoverLeaveTimeoutRef.current = window.setTimeout(() => {
127+
hoverLeaveTimeoutRef.current = null
128+
if (hoverMoveRef.current) {
129+
window.removeEventListener('pointermove', hoverMoveRef.current)
130+
hoverMoveRef.current = null
131+
}
132+
setIsHovered(false)
133+
}, ACTION_MENU_HOVER_LEAVE_DELAY_MS)
134+
}
135+
114136
const scheduleHoverLeave = (event: PointerEvent) => {
115137
if (isOtherNodeTarget(event.relatedTarget)) {
116138
closeHoverImmediately()
117139
return
118140
}
119-
if (hoverLeaveTimeoutRef.current !== null) {
120-
window.clearTimeout(hoverLeaveTimeoutRef.current)
121-
}
122141
if (!hoverMoveRef.current) {
123-
const onMove = (event: PointerEvent) => {
124-
if (isOtherNodeTarget(event.target)) {
142+
/*
143+
* The bar floats above the card, so the pointer crosses a gap to reach
144+
* it and the card's own `pointerleave` fires on the way. This tracks
145+
* the pointer across that gap: inside the bar's hover band the retract
146+
* is cancelled, outside it is re-armed. Both directions matter — no
147+
* further `pointerleave` can arrive once the pointer is off the node,
148+
* so re-arming here is the only way the menu ever closes again.
149+
*/
150+
const onMove = (moveEvent: PointerEvent) => {
151+
if (isOtherNodeTarget(moveEvent.target)) {
125152
closeHoverImmediately()
126153
return
127154
}
128-
if (isPointerOverActionMenu(event)) openHover()
155+
if (isPointerOverActionMenu(moveEvent)) {
156+
cancelHoverLeaveTimeout()
157+
setIsHovered(true)
158+
setSwellOpen(true)
159+
return
160+
}
161+
armHoverLeave()
129162
}
130163
hoverMoveRef.current = onMove
131164
window.addEventListener('pointermove', onMove, { passive: true })
132165
}
133-
hoverLeaveTimeoutRef.current = window.setTimeout(() => {
134-
hoverLeaveTimeoutRef.current = null
135-
if (hoverMoveRef.current) {
136-
window.removeEventListener('pointermove', hoverMoveRef.current)
137-
hoverMoveRef.current = null
138-
}
139-
setIsHovered(false)
140-
}, ACTION_MENU_HOVER_LEAVE_DELAY_MS)
166+
cancelHoverLeaveTimeout()
167+
armHoverLeave()
141168
}
142169

143170
/*

0 commit comments

Comments
 (0)