Skip to content

Commit 139544a

Browse files
committed
fix(uploads): skip per-file invalidation in batch + extract shared API fallback
- Add skipInvalidation flag to useUploadWorkspaceFile; file-upload sub-block now invalidates once after the batch instead of per file - Extract uploadViaApiFallback to lib/uploads/client/api-fallback.ts (DRY across 3 hooks)
1 parent 8e5e67b commit 139544a

6 files changed

Lines changed: 68 additions & 116 deletions

File tree

apps/sim/app/workspace/[workspaceId]/settings/hooks/use-profile-picture-upload.ts

Lines changed: 4 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useCallback, useEffect, useRef, useState } from 'react'
22
import { createLogger } from '@sim/logger'
3+
import { uploadViaApiFallback } from '@/lib/uploads/client/api-fallback'
34
import { DirectUploadError, runUploadStrategy } from '@/lib/uploads/client/direct-upload'
4-
import type { StorageContext } from '@/lib/uploads/shared/types'
55

66
const logger = createLogger('ProfilePictureUpload')
77
const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB
@@ -15,45 +15,6 @@ interface UseProfilePictureUploadProps {
1515
workspaceId?: string
1616
}
1717

18-
/**
19-
* Server-proxied fallback used only when cloud storage isn't configured (local dev).
20-
* Production always takes the presigned PUT path.
21-
*/
22-
async function uploadViaApiFallback(
23-
file: File,
24-
context: StorageContext,
25-
workspaceId?: string
26-
): Promise<string> {
27-
const formData = new FormData()
28-
formData.append('file', file)
29-
formData.append('context', context)
30-
if (workspaceId) {
31-
formData.append('workspaceId', workspaceId)
32-
}
33-
34-
// boundary-raw-fetch: local-dev fallback when cloud storage is not configured; multipart upload incompatible with requestJson
35-
const response = await fetch('/api/files/upload', { method: 'POST', body: formData })
36-
if (!response.ok) {
37-
const errorData = (await response.json().catch(() => ({}))) as {
38-
message?: string
39-
error?: string
40-
}
41-
throw new Error(
42-
errorData.message || errorData.error || `Failed to upload file: ${response.status}`
43-
)
44-
}
45-
const data = (await response.json()) as {
46-
fileInfo?: { path?: string }
47-
path?: string
48-
url?: string
49-
}
50-
const publicUrl = data.fileInfo?.path ?? data.path ?? data.url
51-
if (!publicUrl) {
52-
throw new Error('Invalid upload response: missing path')
53-
}
54-
return publicUrl
55-
}
56-
5718
/**
5819
* Hook for handling profile picture upload functionality.
5920
* Manages file validation, preview generation, and server upload.
@@ -120,9 +81,9 @@ export function useProfilePictureUpload({
12081
return result.path
12182
} catch (error) {
12283
if (error instanceof DirectUploadError && error.code === 'FALLBACK_REQUIRED') {
123-
const publicUrl = await uploadViaApiFallback(file, context, workspaceId)
124-
logger.info(`${context} uploaded successfully via API fallback: ${publicUrl}`)
125-
return publicUrl
84+
const { path } = await uploadViaApiFallback(file, context, workspaceId)
85+
logger.info(`${context} uploaded successfully via API fallback: ${path}`)
86+
return path
12687
}
12788
throw error
12889
}

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments.ts

Lines changed: 6 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { createLogger } from '@sim/logger'
55
import { toError } from '@sim/utils/errors'
66
import { generateId } from '@sim/utils/id'
77
import { toast } from '@/components/emcn'
8+
import { uploadViaApiFallback } from '@/lib/uploads/client/api-fallback'
89
import { DirectUploadError, runUploadStrategy } from '@/lib/uploads/client/direct-upload'
910
import { resolveFileType } from '@/lib/uploads/utils/file-utils'
1011

@@ -52,44 +53,6 @@ interface UseFileAttachmentsProps {
5253
isLoading?: boolean
5354
}
5455

55-
/**
56-
* Server-proxied fallback used only when cloud storage isn't configured (local dev).
57-
* Production always takes the presigned PUT path.
58-
*/
59-
async function uploadViaApiFallback(
60-
file: File,
61-
workspaceId: string
62-
): Promise<{ path: string; key: string }> {
63-
const formData = new FormData()
64-
formData.append('file', file)
65-
formData.append('context', 'mothership')
66-
formData.append('workspaceId', workspaceId)
67-
68-
// boundary-raw-fetch: local-dev fallback when cloud storage is not configured; multipart upload incompatible with requestJson
69-
const response = await fetch('/api/files/upload', { method: 'POST', body: formData })
70-
if (!response.ok) {
71-
const errorData = (await response.json().catch(() => ({}))) as {
72-
message?: string
73-
error?: string
74-
}
75-
throw new Error(
76-
errorData.message || errorData.error || `Failed to upload file: ${response.status}`
77-
)
78-
}
79-
const data = (await response.json()) as {
80-
fileInfo?: { path?: string; key?: string }
81-
path?: string
82-
key?: string
83-
url?: string
84-
}
85-
const path = data.fileInfo?.path ?? data.path ?? data.url
86-
const key = data.fileInfo?.key ?? data.key
87-
if (!path || !key) {
88-
throw new Error('Invalid upload response: missing path or key')
89-
}
90-
return { path, key }
91-
}
92-
9356
/**
9457
* Custom hook to manage file attachments including upload, drag/drop, and preview
9558
* Handles S3 presigned URL uploads and preview URL generation
@@ -193,7 +156,11 @@ export function useFileAttachments(props: UseFileAttachmentsProps) {
193156
})
194157
} catch (error) {
195158
if (error instanceof DirectUploadError && error.code === 'FALLBACK_REQUIRED') {
196-
result = await uploadViaApiFallback(file, workspaceId)
159+
const fallback = await uploadViaApiFallback(file, 'mothership', workspaceId)
160+
if (!fallback.key) {
161+
throw new Error('Invalid upload response: missing key')
162+
}
163+
result = { path: fallback.path, key: fallback.key }
197164
} else {
198165
throw error
199166
}

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { useMemo, useRef, useState } from 'react'
44
import { createLogger } from '@sim/logger'
5+
import { useQueryClient } from '@tanstack/react-query'
56
import { X } from 'lucide-react'
67
import { useParams } from 'next/navigation'
78
import { Button, Combobox } from '@/components/emcn/components'
@@ -12,7 +13,11 @@ import { fileDeleteContract } from '@/lib/api/contracts/storage-transfer'
1213
import { cn } from '@/lib/core/utils/cn'
1314
import { getExtensionFromMimeType } from '@/lib/uploads/utils/file-utils'
1415
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
15-
import { useUploadWorkspaceFile, useWorkspaceFiles } from '@/hooks/queries/workspace-files'
16+
import {
17+
useUploadWorkspaceFile,
18+
useWorkspaceFiles,
19+
workspaceFilesKeys,
20+
} from '@/hooks/queries/workspace-files'
1621
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
1722
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
1823

@@ -166,6 +171,7 @@ export function FileUpload({
166171
} = useWorkspaceFiles(isPreview ? '' : workspaceId)
167172

168173
const uploadFileMutation = useUploadWorkspaceFile()
174+
const queryClient = useQueryClient()
169175

170176
const value = isPreview ? previewValue : storeValue
171177

@@ -316,6 +322,7 @@ export function FileUpload({
316322
workspaceId,
317323
file,
318324
skipToast: true,
325+
skipInvalidation: true,
319326
})
320327

321328
uploadedFiles.push({
@@ -345,6 +352,7 @@ export function FileUpload({
345352

346353
if (workspaceId) {
347354
void refetchWorkspaceFiles()
355+
void queryClient.invalidateQueries({ queryKey: workspaceFilesKeys.storageInfo() })
348356
}
349357

350358
if (uploadedFiles.length === 1) {

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

Lines changed: 4 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,12 @@
11
import { useCallback, useEffect, useRef, useState } from 'react'
22
import { createLogger } from '@sim/logger'
3+
import { uploadViaApiFallback } from '@/lib/uploads/client/api-fallback'
34
import { DirectUploadError, runUploadStrategy } from '@/lib/uploads/client/direct-upload'
45

56
const logger = createLogger('WorkspaceLogoUpload')
67
const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB
78
const ACCEPTED_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/jpg', 'image/svg+xml', 'image/webp']
89

9-
async function uploadViaApiFallback(file: File, workspaceId: string): Promise<string> {
10-
const formData = new FormData()
11-
formData.append('file', file)
12-
formData.append('context', 'workspace-logos')
13-
formData.append('workspaceId', workspaceId)
14-
15-
// boundary-raw-fetch: local-dev fallback when cloud storage is not configured; multipart upload incompatible with requestJson
16-
const response = await fetch('/api/files/upload', { method: 'POST', body: formData })
17-
if (!response.ok) {
18-
const errorData = (await response.json().catch(() => ({}))) as {
19-
message?: string
20-
error?: string
21-
}
22-
throw new Error(
23-
errorData.message || errorData.error || `Failed to upload file: ${response.status}`
24-
)
25-
}
26-
const data = (await response.json()) as {
27-
fileInfo?: { path?: string }
28-
path?: string
29-
url?: string
30-
}
31-
const publicUrl = data.fileInfo?.path ?? data.path ?? data.url
32-
if (!publicUrl) {
33-
throw new Error('Invalid upload response: missing path')
34-
}
35-
return publicUrl
36-
}
37-
3810
interface UseWorkspaceLogoUploadProps {
3911
workspaceId?: string
4012
currentLogoUrl?: string | null
@@ -108,9 +80,9 @@ export function useWorkspaceLogoUpload({
10880
return result.path
10981
} catch (error) {
11082
if (error instanceof DirectUploadError && error.code === 'FALLBACK_REQUIRED') {
111-
const publicUrl = await uploadViaApiFallback(file, targetWorkspaceId)
112-
logger.info(`Workspace logo uploaded via API fallback: ${publicUrl}`)
113-
return publicUrl
83+
const { path } = await uploadViaApiFallback(file, 'workspace-logos', targetWorkspaceId)
84+
logger.info(`Workspace logo uploaded via API fallback: ${path}`)
85+
return path
11486
}
11587
throw error
11688
}

apps/sim/hooks/queries/workspace-files.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,7 @@ interface UploadFileParams {
206206
onProgress?: (event: UploadProgressEvent) => void
207207
signal?: AbortSignal
208208
skipToast?: boolean
209+
skipInvalidation?: boolean
209210
}
210211

211212
interface UploadFileResponse {
@@ -320,7 +321,8 @@ export function useUploadWorkspaceFile() {
320321
return useMutation({
321322
mutationFn: ({ workspaceId, file, onProgress, signal }: UploadFileParams) =>
322323
uploadWorkspaceFile(workspaceId, file, onProgress, signal),
323-
onSettled: () => {
324+
onSettled: (_data, _error, variables) => {
325+
if (variables.skipInvalidation) return
324326
queryClient.invalidateQueries({ queryKey: workspaceFilesKeys.lists() })
325327
queryClient.invalidateQueries({ queryKey: workspaceFilesKeys.storageInfo() })
326328
},
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import type { StorageContext } from '@/lib/uploads/shared/types'
2+
3+
/**
4+
* Server-proxied fallback used only when cloud storage isn't configured (local dev).
5+
* Production always takes the presigned PUT path.
6+
*/
7+
export async function uploadViaApiFallback(
8+
file: File,
9+
context: StorageContext,
10+
workspaceId?: string
11+
): Promise<{ path: string; key?: string }> {
12+
const formData = new FormData()
13+
formData.append('file', file)
14+
formData.append('context', context)
15+
if (workspaceId) {
16+
formData.append('workspaceId', workspaceId)
17+
}
18+
19+
// boundary-raw-fetch: local-dev fallback when cloud storage is not configured; multipart upload incompatible with requestJson
20+
const response = await fetch('/api/files/upload', { method: 'POST', body: formData })
21+
if (!response.ok) {
22+
const errorData = (await response.json().catch(() => ({}))) as {
23+
message?: string
24+
error?: string
25+
}
26+
throw new Error(
27+
errorData.message || errorData.error || `Failed to upload file: ${response.status}`
28+
)
29+
}
30+
const data = (await response.json()) as {
31+
fileInfo?: { path?: string; key?: string }
32+
path?: string
33+
key?: string
34+
url?: string
35+
}
36+
const path = data.fileInfo?.path ?? data.path ?? data.url
37+
const key = data.fileInfo?.key ?? data.key
38+
if (!path) {
39+
throw new Error('Invalid upload response: missing path')
40+
}
41+
return { path, key }
42+
}

0 commit comments

Comments
 (0)