Skip to content

Commit ef0e5a0

Browse files
committed
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.
1 parent 264d4f3 commit ef0e5a0

4 files changed

Lines changed: 235 additions & 19 deletions

File tree

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

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
/*

packages/workflow-renderer/src/workflow-block/workflow-block-border.tsx

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,18 @@ interface WorkflowBlockBorderProps {
150150
cursorSwellEnabled?: boolean
151151
/** Limits pointer-following swells to specific card sides. */
152152
cursorSwellSides?: readonly WorkflowCardSide[]
153+
/**
154+
* Whether hovering this card may raise a swell to drag a connection OUT of
155+
* it. False for cards that mount no source handle, so the swell never
156+
* promises an edge the card cannot start.
157+
*/
158+
canStartConnection?: boolean
159+
/**
160+
* Whether this card may raise a swell while a connection dragged from
161+
* another card passes over it. False for cards that mount no target handle,
162+
* so the swell never promises a drop the card cannot accept.
163+
*/
164+
canReceiveConnection?: boolean
153165
radius?: number
154166
hasRing: boolean
155167
ringStyles: string
@@ -879,6 +891,8 @@ export function WorkflowBlockBorder({
879891
ports,
880892
cursorSwellEnabled = true,
881893
cursorSwellSides,
894+
canStartConnection = true,
895+
canReceiveConnection = true,
882896
radius = 16,
883897
hasRing,
884898
ringStyles,
@@ -1435,6 +1449,13 @@ export function WorkflowBlockBorder({
14351449
// Still over the node chrome (action tab / bridge) — keep listening so
14361450
// returning to an edge immediately restores the swell.
14371451
if (isPointerOverTrackingRoot(clientX, clientY)) {
1452+
/*
1453+
* Drop the magnetized port too. Only the in-band path below
1454+
* recomputes it, so leaving the band upward onto the action bar left
1455+
* the last knob pinned at hover amplitude — the card kept a port
1456+
* puffed out with the pointer nowhere near it.
1457+
*/
1458+
hoveredPortRef.current = null
14381459
cursorHoverAllowedRef.current = false
14391460
cursorAmplitudeRef.current.target = 0
14401461
startAnimation()
@@ -1568,7 +1589,7 @@ export function WorkflowBlockBorder({
15681589

15691590
updatePointerTargetRef.current = updatePointerTarget
15701591
resetPointerTrackingRef.current = stopPointerTracking
1571-
if (cursorSwellEnabled) {
1592+
if (cursorSwellEnabled && canStartConnection) {
15721593
trackingRoot.addEventListener('pointerenter', onPointerEnter)
15731594
trackingRoot.addEventListener('pointerleave', onPointerLeave)
15741595
trackingRoot.addEventListener('pointerdown', onPointerDown, true)
@@ -1609,10 +1630,10 @@ export function WorkflowBlockBorder({
16091630
if (frameRef.current !== null) cancelAnimationFrame(frameRef.current)
16101631
frameRef.current = null
16111632
}
1612-
}, [cursorSwellEnabled, cursorSwellSides])
1633+
}, [canStartConnection, cursorSwellEnabled, cursorSwellSides])
16131634

16141635
useEffect(() => {
1615-
if (!cursorSwellEnabled) {
1636+
if (!cursorSwellEnabled || !canReceiveConnection) {
16161637
resetPointerTrackingRef.current()
16171638
return
16181639
}
@@ -1675,7 +1696,7 @@ export function WorkflowBlockBorder({
16751696
}
16761697
resetPointerTrackingRef.current()
16771698
}
1678-
}, [cursorSwellEnabled, getConnectionNodeId, nodeId])
1699+
}, [canReceiveConnection, cursorSwellEnabled, getConnectionNodeId, nodeId])
16791700

16801701
const ring = resolveRing(ringStyles)
16811702
const { d: path } = renderedPath

packages/workflow-renderer/src/workflow-block/workflow-block-view.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,12 @@ const TAB_LENGTH_HEADER_ONLY_PX = 10
6464
/** The error knob is deliberately the shortest of the connection knobs — it is
6565
* a secondary output, and a full-length tab crowds the card's bottom corner. */
6666
const TAB_HEIGHT_RATIO = 0.5
67+
/**
68+
* Cursor-swell sides for a card that takes no input. Matching the subflow
69+
* start node, only the source edge may swell, so a trigger never raises a knob
70+
* on the edge where every other card shows its input.
71+
*/
72+
const SOURCE_ONLY_CURSOR_SIDES = ['right'] as const
6773
const CARD_CORNER_RADIUS_PX = 16
6874
const CORNER_SLACK_PX = 4
6975
const ACTION_MENU_RIGHT_INSET_PX = 24
@@ -874,6 +880,9 @@ export function WorkflowBlockView({
874880
}
875881
isSelected={usesSelectedVisuals}
876882
height={blockHeight}
883+
cursorSwellSides={shouldShowDefaultHandles ? undefined : SOURCE_ONLY_CURSOR_SIDES}
884+
canStartConnection={supportsCursorHandle}
885+
canReceiveConnection={shouldShowDefaultHandles}
877886
onCursorHandleChange={supportsCursorHandle ? onCursorHandleChange : undefined}
878887
onActionMenuReadyChange={setActionMenuSwellReady}
879888
/>

0 commit comments

Comments
 (0)