| name | vue-flow-debug |
| description | Expert skill for debugging Vue Flow parent-child relationships, coordinate systems, node extent barriers, and nesting logic. Contains deep knowledge on coordinate conversion and event handling. |
| triggers | ["debug vue flow","fix nested nodes","node parent issues","dragging nodes wrong","group node bugs","invisible barrier","node extent","cant drag node"] |
| keywords | ["vue flow","nested","coordinates","parent","computedPosition","extent","barrier","nodeExtent"] |
Vue Flow Nested Nodes & Parent-Child Debugging
🎯 Capabilities
- Coordinate Debugging: Understanding
position vs computedPosition.
- Relationship Fixes: Diagnosing parent-child linkage issues.
- Event Handling: Correct implementation of drag/drop for nested nodes.
- Containment Logic: Advanced geometry checks for "node inside group".
⚡ Action: Debug Protocol
- Analyze: Determine if the issue is visual (rendering), logical (state), or persistent (store).
- Verify: Use the checklists below to validate parent-child integrity.
- Implement: Apply the robust patterns provided for parent assignment.
expert-knowledge.md
1. Vue Flow Coordinate System {#coordinate-system}
Understanding position vs computedPosition
FlowState Rule: Absolute Store, Relative Vue Flow
FlowState stores task.canvasPosition and group.position as absolute world/canvas coordinates. Vue Flow nodes are only a display projection.
- Store/DB/Pinia/PositionManager writes: absolute coordinates.
- Vue Flow root nodes:
position is absolute.
- Vue Flow child nodes with
parentNode: position must be relative to the direct parent.
- Vue Flow
computedPosition is the absolute visual position and is preferred for persistence after drag.
- Every
setNodes()/sync/reload path must convert parented task/group absolute store positions to parent-relative Vue Flow positions.
- Every drag/programmatic write must persist absolute positions back to the store.
Never feed absolute task.canvasPosition directly into a child node's position while also setting parentNode. That causes exactly the FlowState Electron/reload symptom: tasks appear outside their group or drift after restart/update.
FlowState Rule: Programmatic Layout Must Stack Visible Nodes Only
Canvas Tidy and explicit Rotate day groups must use the same layout concept:
- Stack from the group header, not from each task's previous Y.
- Use measured rendered task heights and a small content gap; do not use equal top-edge rows for variable-height cards.
- Build the stack from tasks currently visible on the canvas. Hidden done/overdue/filtered/pinned/completion-record tasks must not consume invisible rows, or users see unexplained blank gaps.
- Keep day/smart groups single-column unless the user explicitly asks for columns.
- Persist store coordinates as absolute
canvasPosition, then project Vue Flow child nodes as parent-relative.
- Do not set
computedPosition; it is internal/derived. Strip Vue Flow internal fields before setNodes.
- After programmatic reparent/restack, use one atomic
setNodes(...), wait for Vue Flow to settle, force a clean store→Vue Flow sync, and release PositionManager/LockManager locks.
Tidy may repair loose tasks that visibly sit inside/below a group column, but it must not move already-parented tasks between groups by due date or geometry. Due-date moves belong to explicit move/rotation metadata flows, not generic Tidy.
node.position (Stored in State)
- For root nodes: position = absolute coordinates on the canvas
- For child nodes (with parentNode set): position = relative to parent's top-left corner
- Stored in: Your nodes array/Pinia store
- Used for: Persistence, serialization, state synchronization
{
id: 'node-1',
position: { x: 100, y: 50 },
parentNode: undefined
}
{
id: 'task-1',
position: { x: 20, y: 30 },
parentNode: 'group-1'
}
node.computedPosition (Calculated at Runtime)
- Always absolute: World coordinates regardless of parent
- Automatically calculated: Vue Flow computes this from position + parent's computedPosition
- Used for: Rendering, collision detection, drag operations
- Read-only: Don't set this directly
Coordinate Transformation Functions
function toRelativePosition(
absolutePos: { x: number; y: number },
parentComputedPos: { x: number; y: number }
): { x: number; y: number } {
return {
x: absolutePos.x - parentComputedPos.x,
y: absolutePos.y - parentComputedPos.y
}
}
function toAbsolutePosition(
relativePos: { x: number; y: number },
parentComputedPos: { x: number; y: number }
): { x: number; y: number } {
return {
x: relativePos.x + parentComputedPos.x,
y: relativePos.y + parentComputedPos.y
}
}
2. Common Bugs & Solutions
Bug #1: Groups Incorrectly Moving Together (Not Nested)
Symptoms: When you drag one group, nearby groups move with it.
Root Cause: parentNode accidentally set or stale references.
Solution: Ensure Group nodes have parentNode: undefined.
Bug #2: Positions Jump on Page Load/Refresh
Root Cause: Loading state before Vue Flow initializes or mismatched coordinate systems.
Solution: Use onPaneReady to gate data loading.
Bug #3: Nested Groups Don't Move with Parent
Root Cause: Child's parentNode not set correctly or position is absolute instead of relative.
Solution: Verify child.parentNode === parent.id.
Bug #4: False Positive Containment (Center-Point Only)
Problem: Standard checks only look at the center point. A large node essentially "outside" a group might have its center "inside", verifying it incorrectly.
Solution: Use Multi-Corner Containment Check.
function isNodeReallyInsideGroup(node: Node, group: Node, margin = 10) {
}
3. Debugging Techniques
Color-Coded Console Logger
Create a consistent logging utility to trace position updates.
Real-Time Position Visualization
Overlay a transparent div showing live computedPosition values to see what Vue Flow "sees".
Diagnostic Containment Check
Run a script that checks all 4 corners of a node against all groups to definitively prove if it "should" be inside.
4. Production Ready Patterns
Reliable Parent Assignment
Do not just check isInside. Check isInside AND ensures the node fits logically. Only assign if confidence is high (>75% coverage).
Syncing External Store
Always listen to onNodesChange and sync position back to your Pinia store. Remember to sync parentNode changes too!
onNodesChange((changes) => {
changes.forEach(change => {
if (change.type === 'position') {
const node = getNode(change.id)
nodeStore.update(change.id, {
position: node.position,
parentNode: node.parentNode
})
}
})
})
5. Critical: Parent-Child Timing Issues (BUG-152)
The Problem
When dropping a task from inbox onto a group:
- ❌ Task count doesn't update
- ❌ Task doesn't move with parent group when dragged
- ✓ Page refresh fixes both issues
Root Cause: Vue Flow's internal parent-child discovery and coordinate calculations need extra time to settle after you replace the nodes array.
The Solution: setNodes() + Double nextTick()
WRONG (Direct Array Mutation):
nodes.value = syncNodes()
await nextTick()
CORRECT (Use setNodes):
import { useVueFlow } from '@vue-flow/core'
const { setNodes, findNode } = useVueFlow()
async function handleDrop(event, taskId, groupId) {
taskStore.updateTask(taskId, {
canvasPosition: { x, y },
isInInbox: false
})
setNodes(syncNodes())
await nextTick()
await nextTick()
const task = findNode(`task-${taskId}`)
console.log('Parent:', task?.parentNode)
}
Why Double nextTick()?
Vue Flow's parent-child discovery needs multiple render cycles:
| Tick | What Happens |
|---|
| 1st | Vue detects array change, updates DOM |
| 2nd | Vue Flow discovers parent-child relationships, recalculates coordinates |
Alternative: updateNode() for Single Node Changes
const { updateNode, findNode } = useVueFlow()
async function handleDrop(taskId, groupId, pos) {
taskStore.updateTask(taskId, updates)
const relativePos = convertToRelativeCoordinates(pos, groupPos)
updateNode(taskId, {
position: relativePos,
parentNode: `section-${groupId}`
})
const groupNode = findNode(`section-${groupId}`)
if (groupNode) {
updateNode(`section-${groupId}`, {
data: {
...groupNode.data,
taskCount: getTaskCountInGroup(groupId)
}
})
}
await nextTick()
await nextTick()
}
Best Practice: Track Parent in Pinia Store
For reliable task counts, track parent-child explicitly in Pinia:
const taskToGroupMap = ref<Record<string, string>>({})
function setTaskParent(taskId: string, parentGroupId: string | null) {
if (parentGroupId) {
taskToGroupMap.value[taskId] = parentGroupId
} else {
delete taskToGroupMap.value[taskId]
}
}
const getTaskCountInGroup = computed(() => (groupId: string) => {
return Object.entries(taskToGroupMap.value)
.filter(([_, gId]) => gId === groupId).length
})
Common Mistakes
| Mistake | Why It Breaks | Fix |
|---|
Direct nodes.value = | Skips Vue Flow initialization | Use setNodes() |
Single nextTick() | Parent-child not discovered yet | Double nextTick() |
Reading from nodes.value in computed | Stale data | Use findNode() |
| Converting to relative twice | Position is wrong | Let Vue Flow handle it OR you handle it, not both |
| Child created before parent | parentNode can't be found | Create parents first in syncNodes() |
Verification Checklist
After implementing, verify:
6. Node Extent Barriers (BUG-1310) {#node-extent}
The Problem
Nodes hit an invisible barrier when dragged. Some groups can be dragged, others cannot. No error messages appear.
Root Cause
Vue Flow's nodeExtent prop constrains where nodes can be positioned. In FlowState, dynamicNodeExtent is computed in useCanvasFilteredState.ts. If the extent is too small, nodes near the edge hit an invisible wall.
Common scenario: When taskNodes=0 (tasks not rendered), the extent used to default to [[-2000, -2000], [5000, 5000]] — only 7000px. Groups at x=4556 had just 444px of room.
Diagnostic Steps
- Check console for
[BUG-1310:EXTENT] — what are the extent bounds?
- Check
[NODE-BUILDER] — are taskNodes > 0?
- Check
[BUG-1310:DRAG-START] — does node have extent: 'parent'? (should be 'none')
- Compare node position vs extent bounds — is it near the edge?
Key Files
src/composables/canvas/useCanvasFilteredState.ts — dynamicNodeExtent computed
src/views/CanvasView.vue — :node-extent="dynamicNodeExtent" prop
Fix Pattern
dynamicNodeExtent must include BOTH task AND group positions. Default should be very large ([-50000, 50000]).
SOP Reference
See docs/sop/canvas/CANVAS-NODE-EXTENT.md for full details.
7. Programmatic Node Position Updates {#programmatic-moves}
The Problem
Updating canvasStore.updateGroup(id, { position }) changes the Pinia store but Vue Flow nodes don't visually move. The sync pipeline (syncStoreToCanvas → setNodes) either gets blocked by canAcceptRemoteUpdate guard or the PositionManager rejects the update.
Root Cause: Sync Pipeline is NOT Designed for Programmatic Moves
The sync pipeline (batchedSyncNodes → syncNodes → syncStoreToCanvas → setNodes) is designed for:
- Initial load (store → Vue Flow projection)
- Remote sync (Supabase realtime → store → Vue Flow)
It has multiple guards that can block execution:
canAcceptRemoteUpdate — blocks if user is dragging/resizing (opState ≠ idle)
canvasSyncInProgress — blocks recursive sync
- PositionManager locks — blocks if node is locked by
user-drag
batchedSyncNodes dedup — skips if already scheduled on this tick
The Correct Approach: useVueFlow().updateNode()
NEVER rely on the sync pipeline for programmatic position changes. Instead, use Vue Flow's updateNode() API directly:
import { useVueFlow } from '@vue-flow/core'
const { updateNode } = useVueFlow()
updateNode('section-group-123', { position: { x: 500, y: 200 } })
This works because:
updateNode() does a shallow Object.assign into the live reactive node
- Vue Flow immediately re-renders the node at the new position
- No sync pipeline, no guards, no PositionManager — direct mutation
Full Pattern for Programmatic Batch Moves
canvasSyncInProgress.value = true
try {
canvasStore.updateGroup(groupId, { position: newPos })
} finally {
canvasSyncInProgress.value = false
}
updateNode(`section-${groupId}`, { position: newPos })
API Comparison
| Method | Moves visually | Persists | Use for |
|---|
updateNode(id, { position }) | ✅ Yes | ❌ No | Vue Flow visual update |
canvasStore.updateGroup(id, { position }) | ❌ No | ✅ Yes | Store persistence |
setNodes(allNodes) | ✅ Yes (but replaces all) | ❌ No | Full graph rebuild only |
findNode(id).position = pos | ✅ Yes | ❌ No | Same as updateNode |
| Sync pipeline (canvasSyncTrigger++) | ⚠️ Maybe (guards) | N/A | Remote sync only |
Key Insight: Two Sync Triggers Exist (They Are Different!)
| Ref | Location | Watcher uses force | Purpose |
|---|
canvasStore.syncTrigger | src/stores/canvas.ts | YES (force: true) | User-initiated re-sync |
canvasSyncTrigger | src/stores/canvasTaskBridge.ts | NO | Remote/automatic sync |
If you bump the wrong one, the sync runs without force and gets blocked by canAcceptRemoteUpdate.
Never Use updateNodePositions (Internal API)
Vue Flow exports updateNodePositions(dragItems, changed, dragging) but the docs explicitly say "you probably don't want to use this" — it's the internal drag handler callback.
Resources
references/
canvas-group-task-counting-tests.md - E2E test patterns for validating group-task counting, coordinate verification, and parent-child relationships