Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
fix(sidebar): use client-generated UUIDs for stable optimistic updates
  • Loading branch information
waleedlatif1 committed Mar 6, 2026
commit 544db9991719b17bf5d49273e7071225b6aeb9df
11 changes: 9 additions & 2 deletions apps/sim/app/api/folders/[id]/duplicate/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const DuplicateRequestSchema = z.object({
workspaceId: z.string().optional(),
parentId: z.string().nullable().optional(),
color: z.string().optional(),
newId: z.string().uuid().optional(),
})

// POST /api/folders/[id]/duplicate - Duplicate a folder with all its child folders and workflows
Expand All @@ -33,7 +34,13 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:

try {
const body = await req.json()
const { name, workspaceId, parentId, color } = DuplicateRequestSchema.parse(body)
const {
name,
workspaceId,
parentId,
color,
newId: clientNewId,
} = DuplicateRequestSchema.parse(body)

logger.info(`[${requestId}] Duplicating folder ${sourceFolderId} for user ${session.user.id}`)

Expand All @@ -60,7 +67,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
const targetWorkspaceId = workspaceId || sourceFolder.workspaceId

const { newFolderId, folderMapping } = await db.transaction(async (tx) => {
const newFolderId = crypto.randomUUID()
const newFolderId = clientNewId || crypto.randomUUID()
const now = new Date()
const targetParentId = parentId ?? sourceFolder.parentId

Expand Down
6 changes: 3 additions & 3 deletions apps/sim/app/api/folders/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export async function POST(request: NextRequest) {
}

const body = await request.json()
const { name, workspaceId, parentId, color, sortOrder: providedSortOrder } = body
const { name, workspaceId, parentId, color, sortOrder: providedSortOrder, id: clientId } = body

if (!name || !workspaceId) {
return NextResponse.json({ error: 'Name and workspace ID are required' }, { status: 400 })
Expand All @@ -79,8 +79,8 @@ export async function POST(request: NextRequest) {
)
}

// Generate a new ID
const id = crypto.randomUUID()
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
const id = clientId && uuidRegex.test(clientId) ? clientId : crypto.randomUUID()

const newFolder = await db.transaction(async (tx) => {
let sortOrder: number
Expand Down
5 changes: 4 additions & 1 deletion apps/sim/app/api/workflows/[id]/duplicate/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const DuplicateRequestSchema = z.object({
color: z.string().optional(),
workspaceId: z.string().optional(),
folderId: z.string().nullable().optional(),
newId: z.string().uuid().optional(),
})

// POST /api/workflows/[id]/duplicate - Duplicate a workflow with all its blocks, edges, and subflows
Expand All @@ -32,7 +33,8 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:

try {
const body = await req.json()
const { name, description, color, workspaceId, folderId } = DuplicateRequestSchema.parse(body)
const { name, description, color, workspaceId, folderId, newId } =
DuplicateRequestSchema.parse(body)

logger.info(`[${requestId}] Duplicating workflow ${sourceWorkflowId} for user ${userId}`)

Expand All @@ -45,6 +47,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
workspaceId,
folderId,
requestId,
newWorkflowId: newId,
})

try {
Expand Down
4 changes: 3 additions & 1 deletion apps/sim/app/api/workflows/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
const logger = createLogger('WorkflowAPI')

const CreateWorkflowSchema = z.object({
id: z.string().uuid().optional(),
name: z.string().min(1, 'Name is required'),
description: z.string().optional().default(''),
color: z.string().optional().default('#3972F6'),
Expand Down Expand Up @@ -109,6 +110,7 @@ export async function POST(req: NextRequest) {
try {
const body = await req.json()
const {
id: clientId,
name,
description,
color,
Expand Down Expand Up @@ -140,7 +142,7 @@ export async function POST(req: NextRequest) {
)
}

const workflowId = crypto.randomUUID()
const workflowId = clientId || crypto.randomUUID()
const now = new Date()

logger.info(`[${requestId}] Creating workflow ${workflowId} for user ${userId}`)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ export function FolderItem({
folderId: folder.id,
name,
color,
id: crypto.randomUUID(),
})

if (result.id) {
Expand All @@ -164,6 +165,7 @@ export function FolderItem({
workspaceId,
name: 'New Folder',
parentId: folder.id,
id: crypto.randomUUID(),
})
if (result.id) {
expandFolder()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,11 @@ export function useFolderOperations({ workspaceId }: UseFolderOperationsProps) {

try {
const folderName = await generateFolderName(workspaceId)
const folder = await createFolderMutation.mutateAsync({ name: folderName, workspaceId })
const folder = await createFolderMutation.mutateAsync({
name: folderName,
workspaceId,
id: crypto.randomUUID(),
})
logger.info(`Created folder: ${folderName}`)
return folder.id
} catch (error) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export function useWorkflowOperations({ workspaceId }: UseWorkflowOperationsProp
workspaceId,
name,
color,
id: crypto.randomUUID(),
})

if (result.id) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ export function useDuplicateFolder({ workspaceId, folderIds, onSuccess }: UseDup
name: duplicateName,
parentId: folder.parentId,
color: folder.color,
newId: crypto.randomUUID(),
})
const newFolderId = result?.id
if (newFolderId) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ export function useDuplicateSelection({ workspaceId, onSuccess }: UseDuplicateSe
name: duplicateName,
parentId: folder.parentId,
color: folder.color,
newId: crypto.randomUUID(),
})

if (result?.id) {
Expand All @@ -109,6 +110,7 @@ export function useDuplicateSelection({ workspaceId, onSuccess }: UseDuplicateSe
description: workflow.description,
color: getNextWorkflowColor(),
folderId: workflow.folderId,
newId: crypto.randomUUID(),
})

duplicatedWorkflowIds.push(result.id)
Expand Down
38 changes: 33 additions & 5 deletions apps/sim/hooks/queries/folders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ interface CreateFolderVariables {
parentId?: string
color?: string
sortOrder?: number
id?: string
}

interface UpdateFolderVariables {
Expand All @@ -90,6 +91,7 @@ interface DuplicateFolderVariables {
name: string
parentId?: string | null
color?: string
newId?: string
}

/**
Expand All @@ -102,13 +104,14 @@ function createFolderMutationHandlers<TVariables extends { workspaceId: string }
variables: TVariables,
tempId: string,
previousFolders: Record<string, WorkflowFolder>
) => WorkflowFolder
) => WorkflowFolder,
customGenerateTempId?: (variables: TVariables) => string
) {
return createOptimisticMutationHandlers<WorkflowFolder, TVariables, WorkflowFolder>(queryClient, {
name,
getQueryKey: (variables) => folderKeys.list(variables.workspaceId),
getSnapshot: () => ({ ...useFolderStore.getState().folders }),
generateTempId: () => generateTempId('temp-folder'),
generateTempId: customGenerateTempId ?? (() => generateTempId('temp-folder')),
createOptimisticItem: (variables, tempId) => {
const previousFolders = useFolderStore.getState().folders
return createOptimisticFolder(variables, tempId, previousFolders)
Expand All @@ -121,11 +124,33 @@ function createFolderMutationHandlers<TVariables extends { workspaceId: string }
replaceOptimisticEntry: (tempId, data) => {
useFolderStore.setState((state) => {
const { [tempId]: _, ...remainingFolders } = state.folders

const expandedFolders = new Set(state.expandedFolders)
const selectedFolders = new Set(state.selectedFolders)
let { lastSelectedFolderId } = state

if (tempId !== data.id) {
if (expandedFolders.has(tempId)) {
expandedFolders.delete(tempId)
expandedFolders.add(data.id)
}
if (selectedFolders.has(tempId)) {
selectedFolders.delete(tempId)
selectedFolders.add(data.id)
}
if (lastSelectedFolderId === tempId) {
lastSelectedFolderId = data.id
}
}

return {
folders: {
...remainingFolders,
[data.id]: data,
},
expandedFolders,
selectedFolders,
lastSelectedFolderId,
Comment thread
waleedlatif1 marked this conversation as resolved.
Outdated
}
})
},
Expand Down Expand Up @@ -163,7 +188,8 @@ export function useCreateFolder() {
createdAt: new Date(),
updatedAt: new Date(),
}
}
},
(variables) => variables.id ?? crypto.randomUUID()
)

return useMutation({
Expand Down Expand Up @@ -241,7 +267,6 @@ export function useDuplicateFolderMutation() {
(variables, tempId, previousFolders) => {
const currentWorkflows = useWorkflowRegistry.getState().workflows

// Get source folder info if available
const sourceFolder = previousFolders[variables.id]
const targetParentId = variables.parentId ?? sourceFolder?.parentId ?? null
return {
Expand All @@ -261,7 +286,8 @@ export function useDuplicateFolderMutation() {
createdAt: new Date(),
updatedAt: new Date(),
}
}
},
(variables) => variables.newId ?? crypto.randomUUID()
)

return useMutation({
Expand All @@ -271,6 +297,7 @@ export function useDuplicateFolderMutation() {
name,
parentId,
color,
newId,
}: DuplicateFolderVariables): Promise<WorkflowFolder> => {
const response = await fetch(`/api/folders/${id}/duplicate`, {
method: 'POST',
Expand All @@ -280,6 +307,7 @@ export function useDuplicateFolderMutation() {
name,
parentId: parentId ?? null,
color,
newId,
}),
})

Expand Down
4 changes: 2 additions & 2 deletions apps/sim/hooks/queries/utils/optimistic-mutation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export interface OptimisticMutationConfig<TData, TVariables, TItem, TContext> {
name: string
getQueryKey: (variables: TVariables) => readonly unknown[]
getSnapshot: () => Record<string, TItem>
generateTempId: () => string
generateTempId: (variables: TVariables) => string
createOptimisticItem: (variables: TVariables, tempId: string) => TItem
applyOptimisticUpdate: (tempId: string, item: TItem) => void
replaceOptimisticEntry: (tempId: string, data: TData) => void
Expand Down Expand Up @@ -41,7 +41,7 @@ export function createOptimisticMutationHandlers<TData, TVariables, TItem>(
const queryKey = getQueryKey(variables)
await queryClient.cancelQueries({ queryKey })
const previousState = getSnapshot()
const tempId = generateTempId()
const tempId = generateTempId(variables)
const optimisticItem = createOptimisticItem(variables, tempId)
applyOptimisticUpdate(tempId, optimisticItem)
logger.info(`[${name}] Added optimistic entry: ${tempId}`)
Expand Down
30 changes: 24 additions & 6 deletions apps/sim/hooks/queries/workflows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ interface CreateWorkflowVariables {
color?: string
folderId?: string | null
sortOrder?: number
id?: string
}

interface CreateWorkflowResult {
Expand All @@ -147,6 +148,7 @@ interface DuplicateWorkflowVariables {
description?: string
color: string
folderId?: string | null
newId?: string
}

interface DuplicateWorkflowResult {
Expand All @@ -168,7 +170,8 @@ interface DuplicateWorkflowResult {
function createWorkflowMutationHandlers<TVariables extends { workspaceId: string }>(
queryClient: ReturnType<typeof useQueryClient>,
name: string,
createOptimisticWorkflow: (variables: TVariables, tempId: string) => WorkflowMetadata
createOptimisticWorkflow: (variables: TVariables, tempId: string) => WorkflowMetadata,
customGenerateTempId?: (variables: TVariables) => string
) {
return createOptimisticMutationHandlers<
CreateWorkflowResult | DuplicateWorkflowResult,
Expand All @@ -178,7 +181,7 @@ function createWorkflowMutationHandlers<TVariables extends { workspaceId: string
name,
getQueryKey: (variables) => workflowKeys.list(variables.workspaceId),
getSnapshot: () => ({ ...useWorkflowRegistry.getState().workflows }),
generateTempId: () => generateTempId('temp-workflow'),
generateTempId: customGenerateTempId ?? (() => generateTempId('temp-workflow')),
createOptimisticItem: createOptimisticWorkflow,
applyOptimisticUpdate: (tempId, item) => {
useWorkflowRegistry.setState((state) => ({
Expand Down Expand Up @@ -206,6 +209,17 @@ function createWorkflowMutationHandlers<TVariables extends { workspaceId: string
error: null,
}
})

if (tempId !== data.id) {
useFolderStore.setState((state) => {
const selectedWorkflows = new Set(state.selectedWorkflows)
if (selectedWorkflows.has(tempId)) {
selectedWorkflows.delete(tempId)
selectedWorkflows.add(data.id)
}
return { selectedWorkflows }
})
}
},
rollback: (snapshot) => {
useWorkflowRegistry.setState({ workflows: snapshot })
Expand Down Expand Up @@ -245,19 +259,21 @@ export function useCreateWorkflow() {
folderId: variables.folderId || null,
sortOrder,
}
}
},
(variables) => variables.id ?? crypto.randomUUID()
)

return useMutation({
mutationFn: async (variables: CreateWorkflowVariables): Promise<CreateWorkflowResult> => {
const { workspaceId, name, description, color, folderId, sortOrder } = variables
const { workspaceId, name, description, color, folderId, sortOrder, id } = variables

logger.info(`Creating new workflow in workspace: ${workspaceId}`)

const createResponse = await fetch('/api/workflows', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id,
name: name || generateCreativeWorkflowName(),
description: description || 'New workflow',
color: color || getNextWorkflowColor(),
Expand Down Expand Up @@ -346,12 +362,13 @@ export function useDuplicateWorkflowMutation() {
targetFolderId
),
}
}
},
(variables) => variables.newId ?? crypto.randomUUID()
)

return useMutation({
mutationFn: async (variables: DuplicateWorkflowVariables): Promise<DuplicateWorkflowResult> => {
const { workspaceId, sourceId, name, description, color, folderId } = variables
const { workspaceId, sourceId, name, description, color, folderId, newId } = variables

logger.info(`Duplicating workflow ${sourceId} in workspace: ${workspaceId}`)

Expand All @@ -364,6 +381,7 @@ export function useDuplicateWorkflowMutation() {
color,
workspaceId,
folderId: folderId ?? null,
newId,
}),
})

Expand Down
Loading