diff --git a/packages/workflow-renderer/src/workflow-block/use-action-menu-swell.test.tsx b/packages/workflow-renderer/src/workflow-block/use-action-menu-swell.test.tsx
new file mode 100644
index 00000000000..47f97de5898
--- /dev/null
+++ b/packages/workflow-renderer/src/workflow-block/use-action-menu-swell.test.tsx
@@ -0,0 +1,162 @@
+/**
+ * @vitest-environment jsdom
+ *
+ * The action bar floats above the card, so the pointer crosses a gap to reach
+ * it and the card's own `pointerleave` fires on the way out. The hook tracks
+ * the pointer across that gap — and the tracking listener is the only thing
+ * still watching once the pointer is off the node, because no further
+ * `pointerleave` can arrive.
+ */
+
+import { act, useEffect } from 'react'
+import { sleep } from '@sim/utils/helpers'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeAll, describe, expect, it } from 'vitest'
+import { useActionMenuSwell } from './use-action-menu-swell'
+
+beforeAll(() => {
+ globalThis.ResizeObserver = class {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ } as unknown as typeof ResizeObserver
+})
+
+/** Clears the hook's 100ms hover-leave delay and its 40ms swell close with margin. */
+const SETTLE_PASS_MS = 160
+const ACTION_MENU_RECT = { left: 100, right: 240, top: 40, bottom: 70 }
+/** Comfortably outside the bar's hover band, which extends 28px above `top`. */
+const AWAY_POINT = { clientX: 600, clientY: 400 }
+
+let container: HTMLDivElement | null = null
+let root: Root | null = null
+
+interface HarnessProps {
+ onState: (state: { swellOpen: boolean }) => void
+}
+
+/**
+ * Mounts the hook under a `.react-flow__node` ancestor, since the hook binds
+ * its hover listeners to that element rather than to its own root.
+ */
+function Harness({ onState }: HarnessProps) {
+ const { rootRef, swellOpen } = useActionMenuSwell({ enabled: true, forceOpen: false })
+
+ useEffect(() => {
+ onState({ swellOpen })
+ }, [onState, swellOpen])
+
+ return (
+
+ )
+}
+
+function mount(onState: (state: { swellOpen: boolean }) => void) {
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+ act(() => {
+ root?.render()
+ })
+
+ const node = container.querySelector('.react-flow__node')
+ const menu = container.querySelector('[data-testid="action-menu"]')
+ if (!node || !menu) throw new Error('harness did not mount')
+
+ menu.getBoundingClientRect = () =>
+ ({
+ ...ACTION_MENU_RECT,
+ width: ACTION_MENU_RECT.right - ACTION_MENU_RECT.left,
+ height: ACTION_MENU_RECT.bottom - ACTION_MENU_RECT.top,
+ x: ACTION_MENU_RECT.left,
+ y: ACTION_MENU_RECT.top,
+ toJSON: () => ({}),
+ }) as DOMRect
+
+ return { node, menu }
+}
+
+function pointerEvent(type: string, init: PointerEventInit) {
+ return new MouseEvent(type, { bubbles: true, ...init }) as unknown as PointerEvent
+}
+
+/**
+ * Drains the two chained timers the retract runs on: the 100ms hover-leave
+ * delay, then the 40ms swell close that its state change schedules. They need
+ * separate `act` passes — effects only flush when `act` exits, so the second
+ * timer is not even created until the first pass is over.
+ */
+async function settle() {
+ for (let pass = 0; pass < 2; pass++) {
+ await act(async () => {
+ await sleep(SETTLE_PASS_MS)
+ })
+ }
+}
+
+afterEach(() => {
+ act(() => {
+ root?.unmount()
+ })
+ root = null
+ container?.remove()
+ container = null
+})
+
+describe('useActionMenuSwell', () => {
+ it('retracts after the pointer crosses the bar and keeps going', async () => {
+ let state = { swellOpen: false }
+ const { node } = mount((next) => {
+ state = next
+ })
+
+ act(() => {
+ node.dispatchEvent(pointerEvent('pointerenter', {}))
+ })
+ expect(state.swellOpen).toBe(true)
+
+ act(() => {
+ node.dispatchEvent(pointerEvent('pointerleave', {}))
+ })
+
+ /*
+ * One move lands inside the bar's hover band on the way out. This is the
+ * regression: it used to cancel the retract AND tear down the tracker, so
+ * nothing was left to close the bar once the pointer carried on.
+ */
+ act(() => {
+ window.dispatchEvent(pointerEvent('pointermove', { clientX: 150, clientY: 50 }))
+ })
+ expect(state.swellOpen).toBe(true)
+
+ act(() => {
+ window.dispatchEvent(pointerEvent('pointermove', AWAY_POINT))
+ })
+
+ await settle()
+
+ expect(state.swellOpen).toBe(false)
+ })
+
+ it('stays open while the pointer rests on the bar', async () => {
+ let state = { swellOpen: false }
+ const { node } = mount((next) => {
+ state = next
+ })
+
+ act(() => {
+ node.dispatchEvent(pointerEvent('pointerenter', {}))
+ node.dispatchEvent(pointerEvent('pointerleave', {}))
+ })
+
+ act(() => {
+ window.dispatchEvent(pointerEvent('pointermove', { clientX: 150, clientY: 50 }))
+ })
+
+ await settle()
+
+ expect(state.swellOpen).toBe(true)
+ })
+})
diff --git a/packages/workflow-renderer/src/workflow-block/use-action-menu-swell.ts b/packages/workflow-renderer/src/workflow-block/use-action-menu-swell.ts
index 0854eea9147..c1df54d0a98 100644
--- a/packages/workflow-renderer/src/workflow-block/use-action-menu-swell.ts
+++ b/packages/workflow-renderer/src/workflow-block/use-action-menu-swell.ts
@@ -59,11 +59,15 @@ export function useActionMenuSwell({
useEffect(() => {
if (!enabled || suspendInteraction) return
- const clearHoverLeave = () => {
+ const cancelHoverLeaveTimeout = () => {
if (hoverLeaveTimeoutRef.current !== null) {
window.clearTimeout(hoverLeaveTimeoutRef.current)
hoverLeaveTimeoutRef.current = null
}
+ }
+
+ const clearHoverLeave = () => {
+ cancelHoverLeaveTimeout()
if (hoverMoveRef.current) {
window.removeEventListener('pointermove', hoverMoveRef.current)
hoverMoveRef.current = null
@@ -111,33 +115,56 @@ export function useActionMenuSwell({
openHover()
}
+ /**
+ * Arms the retract timer, unless one is already counting down. Never
+ * touches the tracking listener: once the pointer has left the node, that
+ * listener is the only thing still watching it, so tearing it down here
+ * would strand the menu open with nothing left to close it.
+ */
+ const armHoverLeave = () => {
+ if (hoverLeaveTimeoutRef.current !== null) return
+ hoverLeaveTimeoutRef.current = window.setTimeout(() => {
+ hoverLeaveTimeoutRef.current = null
+ if (hoverMoveRef.current) {
+ window.removeEventListener('pointermove', hoverMoveRef.current)
+ hoverMoveRef.current = null
+ }
+ setIsHovered(false)
+ }, ACTION_MENU_HOVER_LEAVE_DELAY_MS)
+ }
+
const scheduleHoverLeave = (event: PointerEvent) => {
if (isOtherNodeTarget(event.relatedTarget)) {
closeHoverImmediately()
return
}
- if (hoverLeaveTimeoutRef.current !== null) {
- window.clearTimeout(hoverLeaveTimeoutRef.current)
- }
if (!hoverMoveRef.current) {
- const onMove = (event: PointerEvent) => {
- if (isOtherNodeTarget(event.target)) {
+ /*
+ * The bar floats above the card, so the pointer crosses a gap to reach
+ * it and the card's own `pointerleave` fires on the way. This tracks
+ * the pointer across that gap: inside the bar's hover band the retract
+ * is cancelled, outside it is re-armed. Both directions matter — no
+ * further `pointerleave` can arrive once the pointer is off the node,
+ * so re-arming here is the only way the menu ever closes again.
+ */
+ const onMove = (moveEvent: PointerEvent) => {
+ if (isOtherNodeTarget(moveEvent.target)) {
closeHoverImmediately()
return
}
- if (isPointerOverActionMenu(event)) openHover()
+ if (isPointerOverActionMenu(moveEvent)) {
+ cancelHoverLeaveTimeout()
+ setIsHovered(true)
+ setSwellOpen(true)
+ return
+ }
+ armHoverLeave()
}
hoverMoveRef.current = onMove
window.addEventListener('pointermove', onMove, { passive: true })
}
- hoverLeaveTimeoutRef.current = window.setTimeout(() => {
- hoverLeaveTimeoutRef.current = null
- if (hoverMoveRef.current) {
- window.removeEventListener('pointermove', hoverMoveRef.current)
- hoverMoveRef.current = null
- }
- setIsHovered(false)
- }, ACTION_MENU_HOVER_LEAVE_DELAY_MS)
+ cancelHoverLeaveTimeout()
+ armHoverLeave()
}
/*
diff --git a/packages/workflow-renderer/src/workflow-block/workflow-block-border-connectable.test.tsx b/packages/workflow-renderer/src/workflow-block/workflow-block-border-connectable.test.tsx
new file mode 100644
index 00000000000..13aba144cf6
--- /dev/null
+++ b/packages/workflow-renderer/src/workflow-block/workflow-block-border-connectable.test.tsx
@@ -0,0 +1,206 @@
+/**
+ * @vitest-environment jsdom
+ *
+ * A "port" on a card is a swell painted into the border silhouette, not a DOM
+ * node, so nothing about drawing one forces a handle to exist behind it. These
+ * cover the two directions independently: a card that mounts no source handle
+ * must not raise a swell under its own hover, and a card that mounts no target
+ * handle must not raise one under a connection dragged from somewhere else.
+ */
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
+import { WorkflowBlockBorder, type WorkflowBorderPort } from '../index'
+
+const CARD = { left: 200, top: 100, width: 350, height: 120 }
+const CARD_RECT = {
+ left: CARD.left,
+ top: CARD.top,
+ right: CARD.left + CARD.width,
+ bottom: CARD.top + CARD.height,
+ width: CARD.width,
+ height: CARD.height,
+ x: CARD.left,
+ y: CARD.top,
+ toJSON: () => ({}),
+} as DOMRect
+
+/** Where a source knob sits. */
+const RIGHT_EDGE = { x: CARD.left + CARD.width, y: CARD.top + CARD.height / 2 }
+/** Where a target knob sits. */
+const LEFT_EDGE = { x: CARD.left, y: CARD.top + CARD.height / 2 }
+
+/*
+ * The phantom swell these cover is the pointer-following one, which forms
+ * anywhere on the perimeter; leaving knobs out keeps it from magnetizing into
+ * one and handing the handle off, which is a different path.
+ */
+const PORTS: WorkflowBorderPort[] = []
+
+const mountedRoots = new Set()
+const mountedHosts = new Set()
+
+beforeAll(() => {
+ window.matchMedia = ((query: string) => ({
+ matches: false,
+ media: query,
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ addListener: () => {},
+ removeListener: () => {},
+ onchange: null,
+ dispatchEvent: () => false,
+ })) as unknown as typeof window.matchMedia
+})
+
+beforeEach(() => {
+ vi.useFakeTimers()
+ vi.stubGlobal(
+ 'ResizeObserver',
+ class {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ }
+ )
+ vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
+ window.setTimeout(() => callback(performance.now()), 16)
+ )
+ vi.stubGlobal('cancelAnimationFrame', (frameId: number) => window.clearTimeout(frameId))
+})
+
+afterEach(() => {
+ act(() => {
+ mountedRoots.forEach((root) => root.unmount())
+ })
+ mountedRoots.clear()
+ mountedHosts.forEach((host) => host.remove())
+ mountedHosts.clear()
+ vi.unstubAllGlobals()
+ vi.useRealTimers()
+})
+
+interface MountOptions {
+ connectionNodeId?: string | null
+ canStartConnection?: boolean
+ canReceiveConnection?: boolean
+}
+
+function mountBorder({
+ connectionNodeId = null,
+ canStartConnection,
+ canReceiveConnection,
+}: MountOptions) {
+ const onCursorHandleChange = vi.fn()
+ const host = document.createElement('div')
+ document.body.appendChild(host)
+ const root = createRoot(host)
+ mountedRoots.add(root)
+ mountedHosts.add(host)
+
+ act(() => {
+ root.render(
+ connectionNodeId}
+ ports={PORTS}
+ canStartConnection={canStartConnection}
+ canReceiveConnection={canReceiveConnection}
+ hasRing={false}
+ ringStyles=''
+ width={CARD.width}
+ height={CARD.height}
+ onCursorHandleChange={onCursorHandleChange}
+ />
+ )
+ })
+
+ /*
+ * The tracker measures the swell host — the SVG's parent — and jsdom lays
+ * nothing out, so the card's box has to be supplied.
+ */
+ const svg = host.querySelector('svg')
+ const swellHost = svg?.parentElement
+ if (!swellHost) throw new Error('border did not mount')
+ swellHost.getBoundingClientRect = () => CARD_RECT
+ host.getBoundingClientRect = () => CARD_RECT
+ document.elementFromPoint = () => swellHost
+
+ onCursorHandleChange.mockClear()
+ return { swellHost, onCursorHandleChange }
+}
+
+function pointerEvent(type: string, x: number, y: number) {
+ return new MouseEvent(type, {
+ bubbles: true,
+ clientX: x,
+ clientY: y,
+ }) as unknown as PointerEvent
+}
+
+/**
+ * Handles the border actually offered. A `null` call is the tracker reporting
+ * "no swell", which is the very state these assert, so counting bare calls
+ * would read a correct teardown as a phantom port.
+ */
+function offeredHandles(spy: ReturnType) {
+ return spy.mock.calls.filter(([handle]) => handle != null)
+}
+
+/** Runs the spring far enough for the swell to reach the drawn threshold. */
+function advanceSprings() {
+ act(() => {
+ vi.advanceTimersByTime(500)
+ })
+}
+
+describe('WorkflowBlockBorder connectability', () => {
+ it('raises a swell under its own hover by default', () => {
+ const { swellHost, onCursorHandleChange } = mountBorder({})
+
+ act(() => {
+ swellHost.dispatchEvent(pointerEvent('pointerenter', RIGHT_EDGE.x, RIGHT_EDGE.y))
+ window.dispatchEvent(pointerEvent('pointermove', RIGHT_EDGE.x, RIGHT_EDGE.y))
+ })
+ advanceSprings()
+
+ expect(offeredHandles(onCursorHandleChange).length).toBeGreaterThan(0)
+ })
+
+ it('raises no swell under its own hover when it can start no connection', () => {
+ const { swellHost, onCursorHandleChange } = mountBorder({ canStartConnection: false })
+
+ act(() => {
+ swellHost.dispatchEvent(pointerEvent('pointerenter', RIGHT_EDGE.x, RIGHT_EDGE.y))
+ window.dispatchEvent(pointerEvent('pointermove', RIGHT_EDGE.x, RIGHT_EDGE.y))
+ })
+ advanceSprings()
+
+ expect(offeredHandles(onCursorHandleChange)).toHaveLength(0)
+ })
+
+ it('raises a swell under a connection dragged from another card by default', () => {
+ const { onCursorHandleChange } = mountBorder({ connectionNodeId: 'other-block' })
+
+ act(() => {
+ window.dispatchEvent(pointerEvent('pointermove', LEFT_EDGE.x, LEFT_EDGE.y))
+ })
+ advanceSprings()
+
+ expect(offeredHandles(onCursorHandleChange).length).toBeGreaterThan(0)
+ })
+
+ it('raises no swell under a foreign drag when it can receive no connection', () => {
+ const { onCursorHandleChange } = mountBorder({
+ connectionNodeId: 'other-block',
+ canReceiveConnection: false,
+ })
+
+ act(() => {
+ window.dispatchEvent(pointerEvent('pointermove', LEFT_EDGE.x, LEFT_EDGE.y))
+ })
+ advanceSprings()
+
+ expect(offeredHandles(onCursorHandleChange)).toHaveLength(0)
+ })
+})
diff --git a/packages/workflow-renderer/src/workflow-block/workflow-block-border.tsx b/packages/workflow-renderer/src/workflow-block/workflow-block-border.tsx
index e649c30241d..85bca72b6fa 100644
--- a/packages/workflow-renderer/src/workflow-block/workflow-block-border.tsx
+++ b/packages/workflow-renderer/src/workflow-block/workflow-block-border.tsx
@@ -150,6 +150,18 @@ interface WorkflowBlockBorderProps {
cursorSwellEnabled?: boolean
/** Limits pointer-following swells to specific card sides. */
cursorSwellSides?: readonly WorkflowCardSide[]
+ /**
+ * Whether hovering this card may raise a swell to drag a connection OUT of
+ * it. False for cards that mount no source handle, so the swell never
+ * promises an edge the card cannot start.
+ */
+ canStartConnection?: boolean
+ /**
+ * Whether this card may raise a swell while a connection dragged from
+ * another card passes over it. False for cards that mount no target handle,
+ * so the swell never promises a drop the card cannot accept.
+ */
+ canReceiveConnection?: boolean
radius?: number
hasRing: boolean
ringStyles: string
@@ -879,6 +891,8 @@ export function WorkflowBlockBorder({
ports,
cursorSwellEnabled = true,
cursorSwellSides,
+ canStartConnection = true,
+ canReceiveConnection = true,
radius = 16,
hasRing,
ringStyles,
@@ -1435,6 +1449,13 @@ export function WorkflowBlockBorder({
// Still over the node chrome (action tab / bridge) — keep listening so
// returning to an edge immediately restores the swell.
if (isPointerOverTrackingRoot(clientX, clientY)) {
+ /*
+ * Only the in-band path below recomputes the magnetized port, so
+ * leaving the band upward onto the action bar left the last knob
+ * pinned at hover amplitude — a port puffed out with the pointer
+ * nowhere near it.
+ */
+ hoveredPortRef.current = null
cursorHoverAllowedRef.current = false
cursorAmplitudeRef.current.target = 0
startAnimation()
@@ -1568,7 +1589,7 @@ export function WorkflowBlockBorder({
updatePointerTargetRef.current = updatePointerTarget
resetPointerTrackingRef.current = stopPointerTracking
- if (cursorSwellEnabled) {
+ if (cursorSwellEnabled && canStartConnection) {
trackingRoot.addEventListener('pointerenter', onPointerEnter)
trackingRoot.addEventListener('pointerleave', onPointerLeave)
trackingRoot.addEventListener('pointerdown', onPointerDown, true)
@@ -1609,13 +1630,19 @@ export function WorkflowBlockBorder({
if (frameRef.current !== null) cancelAnimationFrame(frameRef.current)
frameRef.current = null
}
- }, [cursorSwellEnabled, cursorSwellSides])
+ }, [canStartConnection, cursorSwellEnabled, cursorSwellSides])
useEffect(() => {
if (!cursorSwellEnabled) {
resetPointerTrackingRef.current()
return
}
+ /*
+ * Nothing to reset on this exit: the tracker is shared with this card's own
+ * hover, a separate capability, and clearing it would undo the layout
+ * effect's `:hover` bootstrap for a card that mounted under the pointer.
+ */
+ if (!canReceiveConnection) return
/*
* A connection drag captures the pointer on the origin card's handle, so
@@ -1675,7 +1702,7 @@ export function WorkflowBlockBorder({
}
resetPointerTrackingRef.current()
}
- }, [cursorSwellEnabled, getConnectionNodeId, nodeId])
+ }, [canReceiveConnection, cursorSwellEnabled, getConnectionNodeId, nodeId])
const ring = resolveRing(ringStyles)
const { d: path } = renderedPath
diff --git a/packages/workflow-renderer/src/workflow-block/workflow-block-view.tsx b/packages/workflow-renderer/src/workflow-block/workflow-block-view.tsx
index de8e59df2b6..71c7b5ab6d9 100644
--- a/packages/workflow-renderer/src/workflow-block/workflow-block-view.tsx
+++ b/packages/workflow-renderer/src/workflow-block/workflow-block-view.tsx
@@ -64,6 +64,8 @@ const TAB_LENGTH_HEADER_ONLY_PX = 10
/** The error knob is deliberately the shortest of the connection knobs — it is
* a secondary output, and a full-length tab crowds the card's bottom corner. */
const TAB_HEIGHT_RATIO = 0.5
+const DEFAULT_TARGET_SIDE: WorkflowConnectionSide = 'left'
+const DEFAULT_SOURCE_SIDE: WorkflowConnectionSide = 'right'
const CARD_CORNER_RADIUS_PX = 16
const CORNER_SLACK_PX = 4
const ACTION_MENU_RIGHT_INSET_PX = 24
@@ -741,16 +743,14 @@ export function WorkflowBlockView({
const rowTabLength = clampTabLength(
branchRowCount <= 2 ? TAB_LENGTH_SMALL_PX : TAB_LENGTH_SMALL_PX - (branchRowCount - 2) * 2
)
- const defaultTargetSide: WorkflowConnectionSide = 'left'
- const defaultSourceSide: WorkflowConnectionSide = 'right'
const borderPorts = useMemo(() => {
const ports: WorkflowBorderPort[] = []
if (shouldShowDefaultHandles) {
ports.push({
id: WORKFLOW_TARGET_HANDLE_ID,
- side: defaultTargetSide,
+ side: DEFAULT_TARGET_SIDE,
position: 'center',
- plateau: mainTabLength(defaultTargetSide),
+ plateau: mainTabLength(DEFAULT_TARGET_SIDE),
color: tabFill(WORKFLOW_TARGET_HANDLE_ID),
})
}
@@ -780,9 +780,9 @@ export function WorkflowBlockView({
} else if (type !== 'response') {
ports.push({
id: WORKFLOW_SOURCE_HANDLE_ID,
- side: defaultSourceSide,
+ side: DEFAULT_SOURCE_SIDE,
position: 'center',
- plateau: mainTabLength(defaultSourceSide),
+ plateau: mainTabLength(DEFAULT_SOURCE_SIDE),
color: tabFill(WORKFLOW_SOURCE_HANDLE_ID),
})
}
@@ -805,8 +805,6 @@ export function WorkflowBlockView({
conditionRows,
actionMenuSwellOpen,
actionMenuWidth,
- defaultSourceSide,
- defaultTargetSide,
highlightedHandles,
routerRows,
rowTabLength,
@@ -874,6 +872,8 @@ export function WorkflowBlockView({
}
isSelected={usesSelectedVisuals}
height={blockHeight}
+ canStartConnection={supportsCursorHandle}
+ canReceiveConnection={shouldShowDefaultHandles}
onCursorHandleChange={supportsCursorHandle ? onCursorHandleChange : undefined}
onActionMenuReadyChange={setActionMenuSwellReady}
/>