fix(sidebar): use client-generated UUIDs for stable optimistic updates (#3439)

* fix(sidebar): use client-generated UUIDs for stable optimistic updates

* fix(folders): use zod schema validation for folder create API

Replace inline UUID regex with zod schema validation for consistency
with other API routes. Update test expectations accordingly.

* fix(sidebar): add client UUID to single workflow duplicate hook

The useDuplicateWorkflow hook was missing newId: crypto.randomUUID(),
causing the same temp-ID-swap issue for single workflow duplication
from the context menu.

* fix(folders): avoid unnecessary Set re-creation in replaceOptimisticEntry

Only create new expandedFolders/selectedFolders Sets when tempId
differs from data.id. In the common happy path (client-generated UUIDs),
this avoids unnecessary Zustand state reference changes and re-renders.
This commit is contained in:
Waleed
2026-03-06 06:35:19 -08:00
committed by GitHub
parent 0e7c719e82
commit 43509374a2
15 changed files with 121 additions and 30 deletions

View File

@@ -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
@@ -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}`)
@@ -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

View File

@@ -455,7 +455,7 @@ describe('Folders API Route', () => {
expect(response.status).toBe(400)
const data = await response.json()
expect(data).toHaveProperty('error', 'Name and workspace ID are required')
expect(data).toHaveProperty('error', 'Invalid request data')
}
})

View File

@@ -3,12 +3,22 @@ import { workflow, workflowFolder } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, asc, eq, isNull, min } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('FoldersAPI')
const CreateFolderSchema = z.object({
id: z.string().uuid().optional(),
name: z.string().min(1, 'Name is required'),
workspaceId: z.string().min(1, 'Workspace ID is required'),
parentId: z.string().optional(),
color: z.string().optional(),
sortOrder: z.number().int().optional(),
})
// GET - Fetch folders for a workspace
export async function GET(request: NextRequest) {
try {
@@ -59,13 +69,15 @@ export async function POST(request: NextRequest) {
}
const body = await request.json()
const { name, workspaceId, parentId, color, sortOrder: providedSortOrder } = body
const {
id: clientId,
name,
workspaceId,
parentId,
color,
sortOrder: providedSortOrder,
} = CreateFolderSchema.parse(body)
if (!name || !workspaceId) {
return NextResponse.json({ error: 'Name and workspace ID are required' }, { status: 400 })
}
// Check if user has workspace permissions (at least 'write' access to create folders)
const workspacePermission = await getUserEntityPermissions(
session.user.id,
'workspace',
@@ -79,8 +91,7 @@ export async function POST(request: NextRequest) {
)
}
// Generate a new ID
const id = crypto.randomUUID()
const id = clientId || crypto.randomUUID()
const newFolder = await db.transaction(async (tx) => {
let sortOrder: number
@@ -150,6 +161,14 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ folder: newFolder })
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn('Invalid folder creation data', { errors: error.errors })
return NextResponse.json(
{ error: 'Invalid request data', details: error.errors },
{ status: 400 }
)
}
logger.error('Error creating folder:', { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}

View File

@@ -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
@@ -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}`)
@@ -45,6 +47,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
workspaceId,
folderId,
requestId,
newWorkflowId: newId,
})
try {

View File

@@ -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'),
@@ -109,6 +110,7 @@ export async function POST(req: NextRequest) {
try {
const body = await req.json()
const {
id: clientId,
name,
description,
color,
@@ -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}`)

View File

@@ -144,6 +144,7 @@ export function FolderItem({
folderId: folder.id,
name,
color,
id: crypto.randomUUID(),
})
if (result.id) {
@@ -164,6 +165,7 @@ export function FolderItem({
workspaceId,
name: 'New Folder',
parentId: folder.id,
id: crypto.randomUUID(),
})
if (result.id) {
expandFolder()

View File

@@ -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) {

View File

@@ -42,6 +42,7 @@ export function useWorkflowOperations({ workspaceId }: UseWorkflowOperationsProp
workspaceId,
name,
color,
id: crypto.randomUUID(),
})
if (result.id) {

View File

@@ -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) {

View File

@@ -88,6 +88,7 @@ export function useDuplicateSelection({ workspaceId, onSuccess }: UseDuplicateSe
name: duplicateName,
parentId: folder.parentId,
color: folder.color,
newId: crypto.randomUUID(),
})
if (result?.id) {
@@ -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)

View File

@@ -77,6 +77,7 @@ export function useDuplicateWorkflow({ workspaceId, onSuccess }: UseDuplicateWor
description: sourceWorkflow.description,
color: getNextWorkflowColor(),
folderId: sourceWorkflow.folderId,
newId: crypto.randomUUID(),
})
duplicatedIds.push(result.id)

View File

@@ -71,6 +71,7 @@ interface CreateFolderVariables {
parentId?: string
color?: string
sortOrder?: number
id?: string
}
interface UpdateFolderVariables {
@@ -90,6 +91,7 @@ interface DuplicateFolderVariables {
name: string
parentId?: string | null
color?: string
newId?: string
}
/**
@@ -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)
@@ -121,12 +124,36 @@ function createFolderMutationHandlers<TVariables extends { workspaceId: string }
replaceOptimisticEntry: (tempId, data) => {
useFolderStore.setState((state) => {
const { [tempId]: _, ...remainingFolders } = state.folders
return {
const update: Record<string, unknown> = {
folders: {
...remainingFolders,
[data.id]: data,
},
}
if (tempId !== data.id) {
const expandedFolders = new Set(state.expandedFolders)
const selectedFolders = new Set(state.selectedFolders)
if (expandedFolders.has(tempId)) {
expandedFolders.delete(tempId)
expandedFolders.add(data.id)
}
if (selectedFolders.has(tempId)) {
selectedFolders.delete(tempId)
selectedFolders.add(data.id)
}
update.expandedFolders = expandedFolders
update.selectedFolders = selectedFolders
if (state.lastSelectedFolderId === tempId) {
update.lastSelectedFolderId = data.id
}
}
return update
})
},
rollback: (snapshot) => {
@@ -163,7 +190,8 @@ export function useCreateFolder() {
createdAt: new Date(),
updatedAt: new Date(),
}
}
},
(variables) => variables.id ?? crypto.randomUUID()
)
return useMutation({
@@ -241,7 +269,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 {
@@ -261,7 +288,8 @@ export function useDuplicateFolderMutation() {
createdAt: new Date(),
updatedAt: new Date(),
}
}
},
(variables) => variables.newId ?? crypto.randomUUID()
)
return useMutation({
@@ -271,6 +299,7 @@ export function useDuplicateFolderMutation() {
name,
parentId,
color,
newId,
}: DuplicateFolderVariables): Promise<WorkflowFolder> => {
const response = await fetch(`/api/folders/${id}/duplicate`, {
method: 'POST',
@@ -280,6 +309,7 @@ export function useDuplicateFolderMutation() {
name,
parentId: parentId ?? null,
color,
newId,
}),
})

View File

@@ -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
@@ -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}`)

View File

@@ -128,6 +128,7 @@ interface CreateWorkflowVariables {
color?: string
folderId?: string | null
sortOrder?: number
id?: string
}
interface CreateWorkflowResult {
@@ -147,6 +148,7 @@ interface DuplicateWorkflowVariables {
description?: string
color: string
folderId?: string | null
newId?: string
}
interface DuplicateWorkflowResult {
@@ -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,
@@ -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) => ({
@@ -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 })
@@ -245,12 +259,13 @@ 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}`)
@@ -258,6 +273,7 @@ export function useCreateWorkflow() {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id,
name: name || generateCreativeWorkflowName(),
description: description || 'New workflow',
color: color || getNextWorkflowColor(),
@@ -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}`)
@@ -364,6 +381,7 @@ export function useDuplicateWorkflowMutation() {
color,
workspaceId,
folderId: folderId ?? null,
newId,
}),
})

View File

@@ -24,6 +24,7 @@ interface DuplicateWorkflowOptions {
workspaceId?: string
folderId?: string | null
requestId?: string
newWorkflowId?: string
}
interface DuplicateWorkflowResult {
@@ -93,10 +94,10 @@ export async function duplicateWorkflow(
workspaceId,
folderId,
requestId = 'unknown',
newWorkflowId: clientNewWorkflowId,
} = options
// Generate new workflow ID
const newWorkflowId = crypto.randomUUID()
const newWorkflowId = clientNewWorkflowId || crypto.randomUUID()
const now = new Date()
// Duplicate workflow and all related data in a transaction