Skip to content

Commit 4350937

Browse files
authored
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.
1 parent 0e7c719 commit 4350937

File tree

15 files changed

+121
-30
lines changed

15 files changed

+121
-30
lines changed

apps/sim/app/api/folders/[id]/duplicate/route.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ const DuplicateRequestSchema = z.object({
1717
workspaceId: z.string().optional(),
1818
parentId: z.string().nullable().optional(),
1919
color: z.string().optional(),
20+
newId: z.string().uuid().optional(),
2021
})
2122

2223
// 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:
3334

3435
try {
3536
const body = await req.json()
36-
const { name, workspaceId, parentId, color } = DuplicateRequestSchema.parse(body)
37+
const {
38+
name,
39+
workspaceId,
40+
parentId,
41+
color,
42+
newId: clientNewId,
43+
} = DuplicateRequestSchema.parse(body)
3744

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

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

6269
const { newFolderId, folderMapping } = await db.transaction(async (tx) => {
63-
const newFolderId = crypto.randomUUID()
70+
const newFolderId = clientNewId || crypto.randomUUID()
6471
const now = new Date()
6572
const targetParentId = parentId ?? sourceFolder.parentId
6673

apps/sim/app/api/folders/route.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -455,7 +455,7 @@ describe('Folders API Route', () => {
455455
expect(response.status).toBe(400)
456456

457457
const data = await response.json()
458-
expect(data).toHaveProperty('error', 'Name and workspace ID are required')
458+
expect(data).toHaveProperty('error', 'Invalid request data')
459459
}
460460
})
461461

apps/sim/app/api/folders/route.ts

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,22 @@ import { workflow, workflowFolder } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
44
import { and, asc, eq, isNull, min } from 'drizzle-orm'
55
import { type NextRequest, NextResponse } from 'next/server'
6+
import { z } from 'zod'
67
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
78
import { getSession } from '@/lib/auth'
89
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
910

1011
const logger = createLogger('FoldersAPI')
1112

13+
const CreateFolderSchema = z.object({
14+
id: z.string().uuid().optional(),
15+
name: z.string().min(1, 'Name is required'),
16+
workspaceId: z.string().min(1, 'Workspace ID is required'),
17+
parentId: z.string().optional(),
18+
color: z.string().optional(),
19+
sortOrder: z.number().int().optional(),
20+
})
21+
1222
// GET - Fetch folders for a workspace
1323
export async function GET(request: NextRequest) {
1424
try {
@@ -59,13 +69,15 @@ export async function POST(request: NextRequest) {
5969
}
6070

6171
const body = await request.json()
62-
const { name, workspaceId, parentId, color, sortOrder: providedSortOrder } = body
63-
64-
if (!name || !workspaceId) {
65-
return NextResponse.json({ error: 'Name and workspace ID are required' }, { status: 400 })
66-
}
72+
const {
73+
id: clientId,
74+
name,
75+
workspaceId,
76+
parentId,
77+
color,
78+
sortOrder: providedSortOrder,
79+
} = CreateFolderSchema.parse(body)
6780

68-
// Check if user has workspace permissions (at least 'write' access to create folders)
6981
const workspacePermission = await getUserEntityPermissions(
7082
session.user.id,
7183
'workspace',
@@ -79,8 +91,7 @@ export async function POST(request: NextRequest) {
7991
)
8092
}
8193

82-
// Generate a new ID
83-
const id = crypto.randomUUID()
94+
const id = clientId || crypto.randomUUID()
8495

8596
const newFolder = await db.transaction(async (tx) => {
8697
let sortOrder: number
@@ -150,6 +161,14 @@ export async function POST(request: NextRequest) {
150161

151162
return NextResponse.json({ folder: newFolder })
152163
} catch (error) {
164+
if (error instanceof z.ZodError) {
165+
logger.warn('Invalid folder creation data', { errors: error.errors })
166+
return NextResponse.json(
167+
{ error: 'Invalid request data', details: error.errors },
168+
{ status: 400 }
169+
)
170+
}
171+
153172
logger.error('Error creating folder:', { error })
154173
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
155174
}

apps/sim/app/api/workflows/[id]/duplicate/route.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const DuplicateRequestSchema = z.object({
1515
color: z.string().optional(),
1616
workspaceId: z.string().optional(),
1717
folderId: z.string().nullable().optional(),
18+
newId: z.string().uuid().optional(),
1819
})
1920

2021
// 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:
3233

3334
try {
3435
const body = await req.json()
35-
const { name, description, color, workspaceId, folderId } = DuplicateRequestSchema.parse(body)
36+
const { name, description, color, workspaceId, folderId, newId } =
37+
DuplicateRequestSchema.parse(body)
3638

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

@@ -45,6 +47,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
4547
workspaceId,
4648
folderId,
4749
requestId,
50+
newWorkflowId: newId,
4851
})
4952

5053
try {

apps/sim/app/api/workflows/route.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
1313
const logger = createLogger('WorkflowAPI')
1414

1515
const CreateWorkflowSchema = z.object({
16+
id: z.string().uuid().optional(),
1617
name: z.string().min(1, 'Name is required'),
1718
description: z.string().optional().default(''),
1819
color: z.string().optional().default('#3972F6'),
@@ -109,6 +110,7 @@ export async function POST(req: NextRequest) {
109110
try {
110111
const body = await req.json()
111112
const {
113+
id: clientId,
112114
name,
113115
description,
114116
color,
@@ -140,7 +142,7 @@ export async function POST(req: NextRequest) {
140142
)
141143
}
142144

143-
const workflowId = crypto.randomUUID()
145+
const workflowId = clientId || crypto.randomUUID()
144146
const now = new Date()
145147

146148
logger.info(`[${requestId}] Creating workflow ${workflowId} for user ${userId}`)

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ export function FolderItem({
144144
folderId: folder.id,
145145
name,
146146
color,
147+
id: crypto.randomUUID(),
147148
})
148149

149150
if (result.id) {
@@ -164,6 +165,7 @@ export function FolderItem({
164165
workspaceId,
165166
name: 'New Folder',
166167
parentId: folder.id,
168+
id: crypto.randomUUID(),
167169
})
168170
if (result.id) {
169171
expandFolder()

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-folder-operations.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,11 @@ export function useFolderOperations({ workspaceId }: UseFolderOperationsProps) {
2727

2828
try {
2929
const folderName = await generateFolderName(workspaceId)
30-
const folder = await createFolderMutation.mutateAsync({ name: folderName, workspaceId })
30+
const folder = await createFolderMutation.mutateAsync({
31+
name: folderName,
32+
workspaceId,
33+
id: crypto.randomUUID(),
34+
})
3135
logger.info(`Created folder: ${folderName}`)
3236
return folder.id
3337
} catch (error) {

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workflow-operations.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export function useWorkflowOperations({ workspaceId }: UseWorkflowOperationsProp
4242
workspaceId,
4343
name,
4444
color,
45+
id: crypto.randomUUID(),
4546
})
4647

4748
if (result.id) {

apps/sim/app/workspace/[workspaceId]/w/hooks/use-duplicate-folder.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ export function useDuplicateFolder({ workspaceId, folderIds, onSuccess }: UseDup
7777
name: duplicateName,
7878
parentId: folder.parentId,
7979
color: folder.color,
80+
newId: crypto.randomUUID(),
8081
})
8182
const newFolderId = result?.id
8283
if (newFolderId) {

apps/sim/app/workspace/[workspaceId]/w/hooks/use-duplicate-selection.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ export function useDuplicateSelection({ workspaceId, onSuccess }: UseDuplicateSe
8888
name: duplicateName,
8989
parentId: folder.parentId,
9090
color: folder.color,
91+
newId: crypto.randomUUID(),
9192
})
9293

9394
if (result?.id) {
@@ -109,6 +110,7 @@ export function useDuplicateSelection({ workspaceId, onSuccess }: UseDuplicateSe
109110
description: workflow.description,
110111
color: getNextWorkflowColor(),
111112
folderId: workflow.folderId,
113+
newId: crypto.randomUUID(),
112114
})
113115

114116
duplicatedWorkflowIds.push(result.id)

0 commit comments

Comments
 (0)