vue-flow-debug

Expert skill for debugging Vue Flow parent-child relationships, coordinate systems, and nesting logic. Contains deep knowledge on coordinate conversion and event handling.

SKILL.md

GitHub

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

  1. Analyze: Determine if the issue is visual (rendering), logical (state), or persistent (store).
  2. Verify: Use the checklists below to validate parent-child integrity.
  3. 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
// Root node - position is absolute
{
  id: 'node-1',
  position: { x: 100, y: 50 },  // 100px from canvas left, 50px from top
  parentNode: undefined
}

// Child node - position is relative to parent
{
  id: 'task-1',
  position: { x: 20, y: 30 },   // 20px from parent's left, 30px from parent's top
  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

// Absolute (world) to Relative (parent-local)
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
  }
}

// Relative (parent-local) to Absolute (world)
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.

/**
 * Comprehensive containment check using ALL 4 corners + percentage
 */
function isNodeReallyInsideGroup(node: Node, group: Node, margin = 10) {
    // ... See full implementation in guide ...
}

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):

// This doesn't trigger Vue Flow's complete initialization
nodes.value = syncNodes()
await nextTick()
// Vue Flow hasn't finished processing parent-child relationships!

CORRECT (Use setNodes):

import { useVueFlow } from '@vue-flow/core'

const { setNodes, findNode } = useVueFlow()

async function handleDrop(event, taskId, groupId) {
  // 1. Update store
  taskStore.updateTask(taskId, {
    canvasPosition: { x, y },
    isInInbox: false
  })

  // 2. Use setNodes() - triggers Vue Flow's proper initialization
  setNodes(syncNodes())

  // 3. CRITICAL: Double nextTick() for parent-child discovery
  await nextTick()  // First tick: Vue detects change, updates DOM
  await nextTick()  // Second tick: Vue Flow processes parent-child

  // 4. Now safe to read from Vue Flow state
  const task = findNode(`task-${taskId}`)
  console.log('Parent:', task?.parentNode)  // โœ“ Populated
}

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) {
  // 1. Update store
  taskStore.updateTask(taskId, updates)

  // 2. Update only the dropped task
  const relativePos = convertToRelativeCoordinates(pos, groupPos)
  updateNode(taskId, {
    position: relativePos,
    parentNode: `section-${groupId}`
  })

  // 3. Update group's task count
  const groupNode = findNode(`section-${groupId}`)
  if (groupNode) {
    updateNode(`section-${groupId}`, {
      data: {
        ...groupNode.data,
        taskCount: getTaskCountInGroup(groupId)
      }
    })
  }

  // 4. Double nextTick
  await nextTick()
  await nextTick()
}

Best Practice: Track Parent in Pinia Store

For reliable task counts, track parent-child explicitly in Pinia:

// In Pinia store
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:

  • Drop task on group โ†’ count increments immediately
  • Drag group โ†’ task moves with it
  • Refresh page โ†’ state persists correctly
  • Move task between groups โ†’ counts update correctly
  • Rapid drops โ†’ no race conditions

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

  1. Check console for [BUG-1310:EXTENT] โ€” what are the extent bounds?
  2. Check [NODE-BUILDER] โ€” are taskNodes > 0?
  3. Check [BUG-1310:DRAG-START] โ€” does node have extent: 'parent'? (should be 'none')
  4. 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:

  1. canAcceptRemoteUpdate โ€” blocks if user is dragging/resizing (opState โ‰  idle)
  2. canvasSyncInProgress โ€” blocks recursive sync
  3. PositionManager locks โ€” blocks if node is locked by user-drag
  4. 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()

// Move a group node to a new position
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

// 1. Update Pinia store for persistence (suppressing sync)
canvasSyncInProgress.value = true
try {
  canvasStore.updateGroup(groupId, { position: newPos })
  // Also update child task positions if needed
} finally {
  canvasSyncInProgress.value = false
}

// 2. Apply to Vue Flow directly โ€” this is what actually moves the nodes
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