diff --git a/apps/sim/app/api/workflows/[id]/route.ts b/apps/sim/app/api/workflows/[id]/route.ts
index 8cf89b337..e62c245da 100644
--- a/apps/sim/app/api/workflows/[id]/route.ts
+++ b/apps/sim/app/api/workflows/[id]/route.ts
@@ -152,7 +152,23 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ data: finalWorkflowData }, { status: 200 })
}
- return NextResponse.json({ error: 'Workflow has no normalized data' }, { status: 400 })
+
+ const emptyWorkflowData = {
+ ...workflowData,
+ state: {
+ deploymentStatuses: {},
+ blocks: {},
+ edges: [],
+ loops: {},
+ parallels: {},
+ lastSaved: Date.now(),
+ isDeployed: workflowData.isDeployed || false,
+ deployedAt: workflowData.deployedAt,
+ },
+ variables: workflowData.variables || {},
+ }
+
+ return NextResponse.json({ data: emptyWorkflowData }, { status: 200 })
} catch (error: any) {
const elapsed = Date.now() - startTime
logger.error(`[${requestId}] Error fetching workflow ${workflowId} after ${elapsed}ms`, error)
diff --git a/apps/sim/app/workspace/[workspaceId]/layout.tsx b/apps/sim/app/workspace/[workspaceId]/layout.tsx
index 18e9d21ce..4d013ee8a 100644
--- a/apps/sim/app/workspace/[workspaceId]/layout.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/layout.tsx
@@ -14,12 +14,14 @@ export default function WorkspaceLayout({ children }: { children: React.ReactNod
-
-
-
+
>
diff --git a/apps/sim/app/workspace/[workspaceId]/templates/layout.tsx b/apps/sim/app/workspace/[workspaceId]/templates/layout.tsx
index b1177e365..0b6c67610 100644
--- a/apps/sim/app/workspace/[workspaceId]/templates/layout.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/templates/layout.tsx
@@ -1,3 +1,7 @@
export default function TemplatesLayout({ children }: { children: React.ReactNode }) {
- return
{children}
+ return (
+
+ {children}
+
+ )
}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/layout.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/layout.tsx
index 368f2a2ba..a8cbe8fe2 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/layout.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/layout.tsx
@@ -2,7 +2,7 @@ import { ErrorBoundary } from '@/app/workspace/[workspaceId]/w/[workflowId]/comp
export default function WorkflowLayout({ children }: { children: React.ReactNode }) {
return (
-
+
{children}
)
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx
index 896cbffef..72f4e2b9e 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx
@@ -1295,14 +1295,18 @@ const WorkflowContent = React.memo(() => {
return
}
+ // Check if we encountered an error loading this specific workflow to prevent infinite retries
+ const hasLoadError = hydration.phase === 'error' && hydration.workflowId === currentId
+
// Check if we need to load the workflow state:
// 1. Different workflow than currently active
// 2. Same workflow but hydration phase is not 'ready' (e.g., after a quick refresh)
const needsWorkflowLoad =
- activeWorkflowId !== currentId ||
- (activeWorkflowId === currentId &&
- hydration.phase !== 'ready' &&
- hydration.phase !== 'state-loading')
+ !hasLoadError &&
+ (activeWorkflowId !== currentId ||
+ (activeWorkflowId === currentId &&
+ hydration.phase !== 'ready' &&
+ hydration.phase !== 'state-loading'))
if (needsWorkflowLoad) {
const { clearDiff } = useWorkflowDiffStore.getState()
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar-new.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar-new.tsx
index cc03c815b..7cf58d411 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar-new.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar-new.tsx
@@ -60,11 +60,15 @@ export function SidebarNew() {
// Session data
const { data: sessionData, isPending: sessionLoading } = useSession()
- // Sidebar state
- const isCollapsed = useSidebarStore((state) => state.isCollapsed)
+ // Sidebar state - use store's hydration tracking to prevent SSR mismatch
+ const hasHydrated = useSidebarStore((state) => state._hasHydrated)
+ const isCollapsedStore = useSidebarStore((state) => state.isCollapsed)
const setIsCollapsed = useSidebarStore((state) => state.setIsCollapsed)
const setSidebarWidth = useSidebarStore((state) => state.setSidebarWidth)
+ // Use default (expanded) state until hydrated to prevent hydration mismatch
+ const isCollapsed = hasHydrated ? isCollapsedStore : false
+
// Determine if we're on a workflow page (only workflow pages allow collapse and resize)
const isOnWorkflowPage = !!workflowId
diff --git a/apps/sim/app/workspace/[workspaceId]/w/page.tsx b/apps/sim/app/workspace/[workspaceId]/w/page.tsx
index a8f360570..6e50b07de 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/page.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/page.tsx
@@ -1,6 +1,6 @@
'use client'
-import { useEffect, useState } from 'react'
+import { useEffect } from 'react'
import { Loader2 } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { createLogger } from '@/lib/logs/console/logger'
@@ -14,21 +14,12 @@ export default function WorkflowsPage() {
const { workflows, setActiveWorkflow } = useWorkflowRegistry()
const params = useParams()
const workspaceId = params.workspaceId as string
- const [isMounted, setIsMounted] = useState(false)
// Fetch workflows using React Query
const { isLoading, isError } = useWorkflows(workspaceId)
- // Track when component is mounted to avoid hydration issues
+ // Handle redirection once workflows are loaded
useEffect(() => {
- setIsMounted(true)
- }, [])
-
- // Handle redirection once workflows are loaded and component is mounted
- useEffect(() => {
- // Wait for component to be mounted to avoid hydration mismatches
- if (!isMounted) return
-
// Only proceed if workflows are done loading
if (isLoading) return
@@ -50,17 +41,19 @@ export default function WorkflowsPage() {
const firstWorkflowId = workspaceWorkflows[0]
router.replace(`/workspace/${workspaceId}/w/${firstWorkflowId}`)
}
- }, [isMounted, isLoading, workflows, workspaceId, router, setActiveWorkflow, isError])
+ }, [isLoading, workflows, workspaceId, router, setActiveWorkflow, isError])
// Always show loading state until redirect happens
// There should always be a default workflow, so we never show "no workflows found"
return (
-
-
-
+
)
}
diff --git a/apps/sim/lib/workflows/autolayout/core.ts b/apps/sim/lib/workflows/autolayout/core.ts
index 9ff3fb994..013198475 100644
--- a/apps/sim/lib/workflows/autolayout/core.ts
+++ b/apps/sim/lib/workflows/autolayout/core.ts
@@ -3,12 +3,9 @@ import {
CONTAINER_LAYOUT_OPTIONS,
DEFAULT_LAYOUT_OPTIONS,
MAX_OVERLAP_ITERATIONS,
- OVERLAP_MARGIN,
} from '@/lib/workflows/autolayout/constants'
import type { Edge, GraphNode, LayoutOptions } from '@/lib/workflows/autolayout/types'
import {
- boxesOverlap,
- createBoundingBox,
getBlockMetrics,
normalizePositions,
prepareBlockMetrics,
@@ -172,11 +169,10 @@ export function groupByLayer(nodes: Map
): Map {
- if (a.layer !== b.layer) return a.layer - b.layer
- return a.position.y - b.position.y
- })
+ // Group nodes by layer for same-layer overlap resolution
+ const nodesByLayer = new Map()
+ for (const node of nodes) {
+ if (!nodesByLayer.has(node.layer)) {
+ nodesByLayer.set(node.layer, [])
+ }
+ nodesByLayer.get(node.layer)!.push(node)
+ }
- for (let i = 0; i < sortedNodes.length; i++) {
- for (let j = i + 1; j < sortedNodes.length; j++) {
- const node1 = sortedNodes[i]
- const node2 = sortedNodes[j]
+ // Process each layer independently
+ for (const [layer, layerNodes] of nodesByLayer) {
+ if (layerNodes.length < 2) continue
- const box1 = createBoundingBox(node1.position, node1.metrics)
- const box2 = createBoundingBox(node2.position, node2.metrics)
+ // Sort by Y position for consistent processing
+ layerNodes.sort((a, b) => a.position.y - b.position.y)
- // Check for overlap with margin
- if (boxesOverlap(box1, box2, OVERLAP_MARGIN)) {
+ for (let i = 0; i < layerNodes.length - 1; i++) {
+ const node1 = layerNodes[i]
+ const node2 = layerNodes[i + 1]
+
+ const node1Bottom = node1.position.y + node1.metrics.height
+ const requiredY = node1Bottom + verticalSpacing
+
+ if (node2.position.y < requiredY) {
hasOverlap = true
+ node2.position.y = requiredY
- // If in same layer, shift vertically around midpoint
- if (node1.layer === node2.layer) {
- const midpoint = (node1.position.y + node2.position.y) / 2
-
- node1.position.y = midpoint - node1.metrics.height / 2 - verticalSpacing / 2
- node2.position.y = midpoint + node2.metrics.height / 2 + verticalSpacing / 2
- } else {
- // Different layers - shift the later one down
- const requiredSpace = box1.y + box1.height + verticalSpacing
- if (node2.position.y < requiredSpace) {
- node2.position.y = requiredSpace
- }
- }
-
- logger.debug('Resolved overlap between blocks', {
+ logger.debug('Resolved vertical overlap in layer', {
+ layer,
block1: node1.id,
block2: node2.id,
- sameLayer: node1.layer === node2.layer,
iteration,
})
}
@@ -228,14 +219,15 @@ function resolveOverlaps(nodes: GraphNode[], verticalSpacing: number): void {
}
if (hasOverlap) {
- logger.warn('Could not fully resolve all overlaps after max iterations', {
+ logger.warn('Could not fully resolve all vertical overlaps after max iterations', {
iterations: MAX_OVERLAP_ITERATIONS,
})
}
}
/**
- * Calculates positions for nodes organized by layer
+ * Calculates positions for nodes organized by layer.
+ * Uses cumulative width-based X positioning to properly handle containers of varying widths.
*/
export function calculatePositions(
layers: Map,
@@ -248,9 +240,27 @@ export function calculatePositions(
const layerNumbers = Array.from(layers.keys()).sort((a, b) => a - b)
+ // Calculate max width for each layer
+ const layerWidths = new Map()
for (const layerNum of layerNumbers) {
const nodesInLayer = layers.get(layerNum)!
- const xPosition = padding.x + layerNum * horizontalSpacing
+ const maxWidth = Math.max(...nodesInLayer.map((n) => n.metrics.width))
+ layerWidths.set(layerNum, maxWidth)
+ }
+
+ // Calculate cumulative X positions for each layer based on actual widths
+ const layerXPositions = new Map()
+ let cumulativeX = padding.x
+
+ for (const layerNum of layerNumbers) {
+ layerXPositions.set(layerNum, cumulativeX)
+ cumulativeX += layerWidths.get(layerNum)! + horizontalSpacing
+ }
+
+ // Position nodes using cumulative X
+ for (const layerNum of layerNumbers) {
+ const nodesInLayer = layers.get(layerNum)!
+ const xPosition = layerXPositions.get(layerNum)!
// Calculate total height for this layer
const totalHeight = nodesInLayer.reduce(
@@ -285,8 +295,8 @@ export function calculatePositions(
}
}
- // Resolve overlaps across all nodes
- resolveOverlaps(Array.from(layers.values()).flat(), verticalSpacing)
+ // Resolve vertical overlaps within layers (X overlaps prevented by cumulative positioning)
+ resolveVerticalOverlaps(Array.from(layers.values()).flat(), verticalSpacing)
}
/**
diff --git a/apps/sim/lib/workflows/autolayout/index.ts b/apps/sim/lib/workflows/autolayout/index.ts
index bc8f43541..5647f3bae 100644
--- a/apps/sim/lib/workflows/autolayout/index.ts
+++ b/apps/sim/lib/workflows/autolayout/index.ts
@@ -1,4 +1,8 @@
import { createLogger } from '@/lib/logs/console/logger'
+import {
+ DEFAULT_HORIZONTAL_SPACING,
+ DEFAULT_VERTICAL_SPACING,
+} from '@/lib/workflows/autolayout/constants'
import { layoutContainers } from '@/lib/workflows/autolayout/containers'
import { assignLayers, layoutBlocksCore } from '@/lib/workflows/autolayout/core'
import type { Edge, LayoutOptions, LayoutResult } from '@/lib/workflows/autolayout/types'
@@ -6,6 +10,7 @@ import {
calculateSubflowDepths,
filterLayoutEligibleBlockIds,
getBlocksByParent,
+ prepareContainerDimensions,
} from '@/lib/workflows/autolayout/utils'
import type { BlockState } from '@/stores/workflows/workflow/types'
@@ -28,6 +33,19 @@ export function applyAutoLayout(
const blocksCopy: Record = JSON.parse(JSON.stringify(blocks))
+ const horizontalSpacing = options.horizontalSpacing ?? DEFAULT_HORIZONTAL_SPACING
+ const verticalSpacing = options.verticalSpacing ?? DEFAULT_VERTICAL_SPACING
+
+ // Pre-calculate container dimensions by laying out their children (bottom-up)
+ // This ensures accurate widths/heights before root-level layout
+ prepareContainerDimensions(
+ blocksCopy,
+ edges,
+ layoutBlocksCore,
+ horizontalSpacing,
+ verticalSpacing
+ )
+
const { root: rootBlockIds } = getBlocksByParent(blocksCopy)
const layoutRootIds = filterLayoutEligibleBlockIds(rootBlockIds, blocksCopy)
diff --git a/apps/sim/lib/workflows/autolayout/targeted.ts b/apps/sim/lib/workflows/autolayout/targeted.ts
index c0ebbe940..97cb9e071 100644
--- a/apps/sim/lib/workflows/autolayout/targeted.ts
+++ b/apps/sim/lib/workflows/autolayout/targeted.ts
@@ -12,6 +12,7 @@ import {
getBlockMetrics,
getBlocksByParent,
isContainerType,
+ prepareContainerDimensions,
shouldSkipAutoLayout,
} from '@/lib/workflows/autolayout/utils'
import { CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
@@ -47,6 +48,16 @@ export function applyTargetedLayout(
const changedSet = new Set(changedBlockIds)
const blocksCopy: Record = JSON.parse(JSON.stringify(blocks))
+ // Pre-calculate container dimensions by laying out their children (bottom-up)
+ // This ensures accurate widths/heights before root-level layout
+ prepareContainerDimensions(
+ blocksCopy,
+ edges,
+ layoutBlocksCore,
+ horizontalSpacing,
+ verticalSpacing
+ )
+
const groups = getBlocksByParent(blocksCopy)
// Calculate subflow depths before layout to properly position blocks after subflow ends
@@ -109,14 +120,15 @@ function layoutGroup(
const requestedLayout = layoutEligibleChildIds.filter((id) => {
const block = blocks[id]
if (!block) return false
- // Never reposition containers, only update their dimensions
- if (isContainerType(block.type)) return false
+ if (isContainerType(block.type)) {
+ return changedSet.has(id) && isDefaultPosition(block)
+ }
return changedSet.has(id)
})
const missingPositions = layoutEligibleChildIds.filter((id) => {
const block = blocks[id]
if (!block) return false
- return !hasPosition(block)
+ return !hasPosition(block) || isDefaultPosition(block)
})
const needsLayoutSet = new Set([...requestedLayout, ...missingPositions])
const needsLayout = Array.from(needsLayoutSet)
@@ -308,3 +320,13 @@ function hasPosition(block: BlockState): boolean {
const { x, y } = block.position
return Number.isFinite(x) && Number.isFinite(y)
}
+
+/**
+ * Checks if a block is at the default/uninitialized position (0, 0).
+ * New blocks typically start at this position before being laid out.
+ */
+function isDefaultPosition(block: BlockState): boolean {
+ if (!block.position) return true
+ const { x, y } = block.position
+ return x === 0 && y === 0
+}
diff --git a/apps/sim/lib/workflows/autolayout/utils.ts b/apps/sim/lib/workflows/autolayout/utils.ts
index a4cf5084e..7c63edc81 100644
--- a/apps/sim/lib/workflows/autolayout/utils.ts
+++ b/apps/sim/lib/workflows/autolayout/utils.ts
@@ -315,3 +315,126 @@ export function calculateSubflowDepths(
return depths
}
+
+/**
+ * Layout function type for preparing container dimensions.
+ * Returns laid out nodes and bounding dimensions.
+ */
+export type LayoutFunction = (
+ blocks: Record,
+ edges: Edge[],
+ options: {
+ isContainer: boolean
+ layoutOptions?: {
+ horizontalSpacing?: number
+ verticalSpacing?: number
+ padding?: { x: number; y: number }
+ alignment?: 'start' | 'center' | 'end'
+ }
+ subflowDepths?: Map
+ }
+) => { nodes: Map; dimensions: { width: number; height: number } }
+
+/**
+ * Pre-calculates container dimensions by laying out their children.
+ * Processes containers bottom-up to handle nested subflows correctly.
+ * This ensures accurate width/height values before root-level layout.
+ *
+ * @param blocks - All blocks in the workflow (will be mutated with updated dimensions)
+ * @param edges - All edges in the workflow
+ * @param layoutFn - The layout function to use for calculating dimensions
+ * @param horizontalSpacing - Horizontal spacing between blocks
+ * @param verticalSpacing - Vertical spacing between blocks
+ */
+export function prepareContainerDimensions(
+ blocks: Record,
+ edges: Edge[],
+ layoutFn: LayoutFunction,
+ horizontalSpacing: number,
+ verticalSpacing: number
+): void {
+ const { children } = getBlocksByParent(blocks)
+
+ // Build dependency graph to process nested containers bottom-up
+ const containerIds = Array.from(children.keys())
+ const containerDepth = new Map()
+
+ // Calculate nesting depth for each container
+ for (const containerId of containerIds) {
+ let depth = 0
+ let currentId: string | undefined = containerId
+ while (currentId) {
+ const block: BlockState | undefined = blocks[currentId]
+ const parentId: string | undefined = block?.data?.parentId
+ currentId = parentId
+ if (currentId) depth++
+ }
+ containerDepth.set(containerId, depth)
+ }
+
+ // Sort containers by depth (deepest first) for bottom-up processing
+ const sortedContainerIds = containerIds.sort((a, b) => {
+ const depthA = containerDepth.get(a) ?? 0
+ const depthB = containerDepth.get(b) ?? 0
+ return depthB - depthA
+ })
+
+ // Process each container, laying out its children to determine dimensions
+ for (const containerId of sortedContainerIds) {
+ const container = blocks[containerId]
+ if (!container) continue
+
+ const childIds = children.get(containerId) ?? []
+ const layoutChildIds = filterLayoutEligibleBlockIds(childIds, blocks)
+
+ if (layoutChildIds.length === 0) {
+ // Empty container - use default dimensions
+ container.data = {
+ ...container.data,
+ width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
+ height: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
+ }
+ container.layout = {
+ ...container.layout,
+ measuredWidth: CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
+ measuredHeight: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
+ }
+ continue
+ }
+
+ // Build subset of blocks and edges for this container's children
+ const childBlocks: Record = {}
+ for (const childId of layoutChildIds) {
+ childBlocks[childId] = blocks[childId]
+ }
+
+ const childEdges = edges.filter(
+ (edge) => layoutChildIds.includes(edge.source) && layoutChildIds.includes(edge.target)
+ )
+
+ // Layout children to get dimensions
+ const { dimensions } = layoutFn(childBlocks, childEdges, {
+ isContainer: true,
+ layoutOptions: {
+ horizontalSpacing: horizontalSpacing * 0.85,
+ verticalSpacing,
+ alignment: 'center',
+ },
+ })
+
+ // Update container with calculated dimensions
+ const calculatedWidth = Math.max(dimensions.width, CONTAINER_DIMENSIONS.DEFAULT_WIDTH)
+ const calculatedHeight = Math.max(dimensions.height, CONTAINER_DIMENSIONS.DEFAULT_HEIGHT)
+
+ container.data = {
+ ...container.data,
+ width: calculatedWidth,
+ height: calculatedHeight,
+ }
+ container.layout = {
+ ...container.layout,
+ measuredWidth: calculatedWidth,
+ measuredHeight: calculatedHeight,
+ }
+ }
+}
diff --git a/apps/sim/stores/sidebar/store.ts b/apps/sim/stores/sidebar/store.ts
index 47da89629..139330eec 100644
--- a/apps/sim/stores/sidebar/store.ts
+++ b/apps/sim/stores/sidebar/store.ts
@@ -8,9 +8,11 @@ interface SidebarState {
workspaceDropdownOpen: boolean
sidebarWidth: number
isCollapsed: boolean
+ _hasHydrated: boolean
setWorkspaceDropdownOpen: (isOpen: boolean) => void
setSidebarWidth: (width: number) => void
setIsCollapsed: (isCollapsed: boolean) => void
+ setHasHydrated: (hasHydrated: boolean) => void
}
/**
@@ -26,6 +28,7 @@ export const useSidebarStore = create()(
workspaceDropdownOpen: false,
sidebarWidth: DEFAULT_SIDEBAR_WIDTH,
isCollapsed: false,
+ _hasHydrated: false,
setWorkspaceDropdownOpen: (isOpen) => set({ workspaceDropdownOpen: isOpen }),
setSidebarWidth: (width) => {
// Only enforce minimum - maximum is enforced dynamically by the resize hook
@@ -47,17 +50,24 @@ export const useSidebarStore = create()(
document.documentElement.style.setProperty('--sidebar-width', `${currentWidth}px`)
}
},
+ setHasHydrated: (hasHydrated) => set({ _hasHydrated: hasHydrated }),
}),
{
name: 'sidebar-state',
onRehydrateStorage: () => (state) => {
- // Validate and enforce constraints after rehydration
- if (state && typeof window !== 'undefined') {
- // Use 0 width if collapsed (floating UI), otherwise use stored width
- const width = state.isCollapsed ? 0 : state.sidebarWidth
- document.documentElement.style.setProperty('--sidebar-width', `${width}px`)
+ // Mark store as hydrated and apply CSS variables
+ if (state) {
+ state.setHasHydrated(true)
+ if (typeof window !== 'undefined') {
+ const width = state.isCollapsed ? 0 : state.sidebarWidth
+ document.documentElement.style.setProperty('--sidebar-width', `${width}px`)
+ }
}
},
+ partialize: (state) => ({
+ sidebarWidth: state.sidebarWidth,
+ isCollapsed: state.isCollapsed,
+ }),
}
)
)
diff --git a/apps/sim/tsconfig.json b/apps/sim/tsconfig.json
index abaee9897..f48d70e63 100644
--- a/apps/sim/tsconfig.json
+++ b/apps/sim/tsconfig.json
@@ -35,7 +35,7 @@
"resolveJsonModule": true,
"isolatedModules": true,
"allowImportingTsExtensions": true,
- "jsx": "preserve",
+ "jsx": "react-jsx",
"plugins": [
{
"name": "next"