Skip to content

Commit 961df01

Browse files
committed
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 ef0e5a0 commit 961df01

4 files changed

Lines changed: 228 additions & 22 deletions

File tree

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@
77
* still watching once the pointer is off the node, because no further
88
* `pointerleave` can arrive.
99
*/
10+
1011
import { act, useEffect } from 'react'
12+
import { sleep } from '@sim/utils/helpers'
1113
import { createRoot, type Root } from 'react-dom/client'
1214
import { afterEach, beforeAll, describe, expect, it } from 'vitest'
1315
import { useActionMenuSwell } from './use-action-menu-swell'
@@ -20,6 +22,8 @@ beforeAll(() => {
2022
} as unknown as typeof ResizeObserver
2123
})
2224

25+
/** Clears the hook's 100ms hover-leave delay and its 40ms swell close with margin. */
26+
const SETTLE_PASS_MS = 160
2327
const ACTION_MENU_RECT = { left: 100, right: 240, top: 40, bottom: 70 }
2428
/** Comfortably outside the bar's hover band, which extends 28px above `top`. */
2529
const AWAY_POINT = { clientX: 600, clientY: 400 }
@@ -87,7 +91,7 @@ function pointerEvent(type: string, init: PointerEventInit) {
8791
async function settle() {
8892
for (let pass = 0; pass < 2; pass++) {
8993
await act(async () => {
90-
await new Promise((resolve) => setTimeout(resolve, 160))
94+
await sleep(SETTLE_PASS_MS)
9195
})
9296
}
9397
}
@@ -113,7 +117,6 @@ describe('useActionMenuSwell', () => {
113117
})
114118
expect(state.swellOpen).toBe(true)
115119

116-
/* Leaving the card arms the retract and installs the gap tracker. */
117120
act(() => {
118121
node.dispatchEvent(pointerEvent('pointerleave', {}))
119122
})
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*
4+
* A "port" on a card is a swell painted into the border silhouette, not a DOM
5+
* node, so nothing about drawing one forces a handle to exist behind it. These
6+
* cover the two directions independently: a card that mounts no source handle
7+
* must not raise a swell under its own hover, and a card that mounts no target
8+
* handle must not raise one under a connection dragged from somewhere else.
9+
*/
10+
import { act } from 'react'
11+
import { createRoot, type Root } from 'react-dom/client'
12+
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
13+
import { WorkflowBlockBorder, type WorkflowBorderPort } from '../index'
14+
15+
const CARD = { left: 200, top: 100, width: 350, height: 120 }
16+
const CARD_RECT = {
17+
left: CARD.left,
18+
top: CARD.top,
19+
right: CARD.left + CARD.width,
20+
bottom: CARD.top + CARD.height,
21+
width: CARD.width,
22+
height: CARD.height,
23+
x: CARD.left,
24+
y: CARD.top,
25+
toJSON: () => ({}),
26+
} as DOMRect
27+
28+
/** Where a source knob sits. */
29+
const RIGHT_EDGE = { x: CARD.left + CARD.width, y: CARD.top + CARD.height / 2 }
30+
/** Where a target knob sits. */
31+
const LEFT_EDGE = { x: CARD.left, y: CARD.top + CARD.height / 2 }
32+
33+
/*
34+
* The phantom swell these cover is the pointer-following one, which forms
35+
* anywhere on the perimeter; leaving knobs out keeps it from magnetizing into
36+
* one and handing the handle off, which is a different path.
37+
*/
38+
const PORTS: WorkflowBorderPort[] = []
39+
40+
const mountedRoots = new Set<Root>()
41+
const mountedHosts = new Set<HTMLDivElement>()
42+
43+
beforeAll(() => {
44+
window.matchMedia = ((query: string) => ({
45+
matches: false,
46+
media: query,
47+
addEventListener: () => {},
48+
removeEventListener: () => {},
49+
addListener: () => {},
50+
removeListener: () => {},
51+
onchange: null,
52+
dispatchEvent: () => false,
53+
})) as unknown as typeof window.matchMedia
54+
})
55+
56+
beforeEach(() => {
57+
vi.useFakeTimers()
58+
vi.stubGlobal(
59+
'ResizeObserver',
60+
class {
61+
observe() {}
62+
unobserve() {}
63+
disconnect() {}
64+
}
65+
)
66+
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
67+
window.setTimeout(() => callback(performance.now()), 16)
68+
)
69+
vi.stubGlobal('cancelAnimationFrame', (frameId: number) => window.clearTimeout(frameId))
70+
})
71+
72+
afterEach(() => {
73+
act(() => {
74+
mountedRoots.forEach((root) => root.unmount())
75+
})
76+
mountedRoots.clear()
77+
mountedHosts.forEach((host) => host.remove())
78+
mountedHosts.clear()
79+
vi.unstubAllGlobals()
80+
vi.useRealTimers()
81+
})
82+
83+
interface MountOptions {
84+
connectionNodeId?: string | null
85+
canStartConnection?: boolean
86+
canReceiveConnection?: boolean
87+
}
88+
89+
function mountBorder({
90+
connectionNodeId = null,
91+
canStartConnection,
92+
canReceiveConnection,
93+
}: MountOptions) {
94+
const onCursorHandleChange = vi.fn()
95+
const host = document.createElement('div')
96+
document.body.appendChild(host)
97+
const root = createRoot(host)
98+
mountedRoots.add(root)
99+
mountedHosts.add(host)
100+
101+
act(() => {
102+
root.render(
103+
<WorkflowBlockBorder
104+
nodeId='block-1'
105+
getConnectionNodeId={() => connectionNodeId}
106+
ports={PORTS}
107+
canStartConnection={canStartConnection}
108+
canReceiveConnection={canReceiveConnection}
109+
hasRing={false}
110+
ringStyles=''
111+
width={CARD.width}
112+
height={CARD.height}
113+
onCursorHandleChange={onCursorHandleChange}
114+
/>
115+
)
116+
})
117+
118+
/*
119+
* The tracker measures the swell host — the SVG's parent — and jsdom lays
120+
* nothing out, so the card's box has to be supplied.
121+
*/
122+
const svg = host.querySelector('svg')
123+
const swellHost = svg?.parentElement
124+
if (!swellHost) throw new Error('border did not mount')
125+
swellHost.getBoundingClientRect = () => CARD_RECT
126+
host.getBoundingClientRect = () => CARD_RECT
127+
document.elementFromPoint = () => swellHost
128+
129+
onCursorHandleChange.mockClear()
130+
return { swellHost, onCursorHandleChange }
131+
}
132+
133+
function pointerEvent(type: string, x: number, y: number) {
134+
return new MouseEvent(type, {
135+
bubbles: true,
136+
clientX: x,
137+
clientY: y,
138+
}) as unknown as PointerEvent
139+
}
140+
141+
/**
142+
* Handles the border actually offered. A `null` call is the tracker reporting
143+
* "no swell", which is the very state these assert, so counting bare calls
144+
* would read a correct teardown as a phantom port.
145+
*/
146+
function offeredHandles(spy: ReturnType<typeof vi.fn>) {
147+
return spy.mock.calls.filter(([handle]) => handle != null)
148+
}
149+
150+
/** Runs the spring far enough for the swell to reach the drawn threshold. */
151+
function advanceSprings() {
152+
act(() => {
153+
vi.advanceTimersByTime(500)
154+
})
155+
}
156+
157+
describe('WorkflowBlockBorder connectability', () => {
158+
it('raises a swell under its own hover by default', () => {
159+
const { swellHost, onCursorHandleChange } = mountBorder({})
160+
161+
act(() => {
162+
swellHost.dispatchEvent(pointerEvent('pointerenter', RIGHT_EDGE.x, RIGHT_EDGE.y))
163+
window.dispatchEvent(pointerEvent('pointermove', RIGHT_EDGE.x, RIGHT_EDGE.y))
164+
})
165+
advanceSprings()
166+
167+
expect(offeredHandles(onCursorHandleChange).length).toBeGreaterThan(0)
168+
})
169+
170+
it('raises no swell under its own hover when it can start no connection', () => {
171+
const { swellHost, onCursorHandleChange } = mountBorder({ canStartConnection: false })
172+
173+
act(() => {
174+
swellHost.dispatchEvent(pointerEvent('pointerenter', RIGHT_EDGE.x, RIGHT_EDGE.y))
175+
window.dispatchEvent(pointerEvent('pointermove', RIGHT_EDGE.x, RIGHT_EDGE.y))
176+
})
177+
advanceSprings()
178+
179+
expect(offeredHandles(onCursorHandleChange)).toHaveLength(0)
180+
})
181+
182+
it('raises a swell under a connection dragged from another card by default', () => {
183+
const { onCursorHandleChange } = mountBorder({ connectionNodeId: 'other-block' })
184+
185+
act(() => {
186+
window.dispatchEvent(pointerEvent('pointermove', LEFT_EDGE.x, LEFT_EDGE.y))
187+
})
188+
advanceSprings()
189+
190+
expect(offeredHandles(onCursorHandleChange).length).toBeGreaterThan(0)
191+
})
192+
193+
it('raises no swell under a foreign drag when it can receive no connection', () => {
194+
const { onCursorHandleChange } = mountBorder({
195+
connectionNodeId: 'other-block',
196+
canReceiveConnection: false,
197+
})
198+
199+
act(() => {
200+
window.dispatchEvent(pointerEvent('pointermove', LEFT_EDGE.x, LEFT_EDGE.y))
201+
})
202+
advanceSprings()
203+
204+
expect(offeredHandles(onCursorHandleChange)).toHaveLength(0)
205+
})
206+
})

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

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1450,10 +1450,10 @@ export function WorkflowBlockBorder({
14501450
// returning to an edge immediately restores the swell.
14511451
if (isPointerOverTrackingRoot(clientX, clientY)) {
14521452
/*
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.
1453+
* Only the in-band path below recomputes the magnetized port, so
1454+
* leaving the band upward onto the action bar left the last knob
1455+
* pinned at hover amplitude — a port puffed out with the pointer
1456+
* nowhere near it.
14571457
*/
14581458
hoveredPortRef.current = null
14591459
cursorHoverAllowedRef.current = false
@@ -1633,10 +1633,16 @@ export function WorkflowBlockBorder({
16331633
}, [canStartConnection, cursorSwellEnabled, cursorSwellSides])
16341634

16351635
useEffect(() => {
1636-
if (!cursorSwellEnabled || !canReceiveConnection) {
1636+
if (!cursorSwellEnabled) {
16371637
resetPointerTrackingRef.current()
16381638
return
16391639
}
1640+
/*
1641+
* Nothing to reset on this exit: the tracker is shared with this card's own
1642+
* hover, a separate capability, and clearing it would undo the layout
1643+
* effect's `:hover` bootstrap for a card that mounted under the pointer.
1644+
*/
1645+
if (!canReceiveConnection) return
16401646

16411647
/*
16421648
* A connection drag captures the pointer on the origin card's handle, so

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

Lines changed: 6 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -64,12 +64,8 @@ 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
67+
const DEFAULT_TARGET_SIDE: WorkflowConnectionSide = 'left'
68+
const DEFAULT_SOURCE_SIDE: WorkflowConnectionSide = 'right'
7369
const CARD_CORNER_RADIUS_PX = 16
7470
const CORNER_SLACK_PX = 4
7571
const ACTION_MENU_RIGHT_INSET_PX = 24
@@ -747,16 +743,14 @@ export function WorkflowBlockView({
747743
const rowTabLength = clampTabLength(
748744
branchRowCount <= 2 ? TAB_LENGTH_SMALL_PX : TAB_LENGTH_SMALL_PX - (branchRowCount - 2) * 2
749745
)
750-
const defaultTargetSide: WorkflowConnectionSide = 'left'
751-
const defaultSourceSide: WorkflowConnectionSide = 'right'
752746
const borderPorts = useMemo<WorkflowBorderPort[]>(() => {
753747
const ports: WorkflowBorderPort[] = []
754748
if (shouldShowDefaultHandles) {
755749
ports.push({
756750
id: WORKFLOW_TARGET_HANDLE_ID,
757-
side: defaultTargetSide,
751+
side: DEFAULT_TARGET_SIDE,
758752
position: 'center',
759-
plateau: mainTabLength(defaultTargetSide),
753+
plateau: mainTabLength(DEFAULT_TARGET_SIDE),
760754
color: tabFill(WORKFLOW_TARGET_HANDLE_ID),
761755
})
762756
}
@@ -786,9 +780,9 @@ export function WorkflowBlockView({
786780
} else if (type !== 'response') {
787781
ports.push({
788782
id: WORKFLOW_SOURCE_HANDLE_ID,
789-
side: defaultSourceSide,
783+
side: DEFAULT_SOURCE_SIDE,
790784
position: 'center',
791-
plateau: mainTabLength(defaultSourceSide),
785+
plateau: mainTabLength(DEFAULT_SOURCE_SIDE),
792786
color: tabFill(WORKFLOW_SOURCE_HANDLE_ID),
793787
})
794788
}
@@ -811,8 +805,6 @@ export function WorkflowBlockView({
811805
conditionRows,
812806
actionMenuSwellOpen,
813807
actionMenuWidth,
814-
defaultSourceSide,
815-
defaultTargetSide,
816808
highlightedHandles,
817809
routerRows,
818810
rowTabLength,
@@ -880,7 +872,6 @@ export function WorkflowBlockView({
880872
}
881873
isSelected={usesSelectedVisuals}
882874
height={blockHeight}
883-
cursorSwellSides={shouldShowDefaultHandles ? undefined : SOURCE_ONLY_CURSOR_SIDES}
884875
canStartConnection={supportsCursorHandle}
885876
canReceiveConnection={shouldShowDefaultHandles}
886877
onCursorHandleChange={supportsCursorHandle ? onCursorHandleChange : undefined}

0 commit comments

Comments
 (0)